"""Tests for netx HTTP MCP server.""" from __future__ import annotations import json import subprocess import sys from unittest.mock import patch import pytest 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) == 14 assert "queryUmeAlarms" in names assert "queryUmeAlarmsRaw" in names assert "execManagedNe" in names assert "listCliTargets" in names assert "findTopologyPaths" in names assert "queryTopologyEdges" not in names exec_tool = next(t for t in HTTP_MCP_TOOLS if t.get("name") == "execManagedNe") assert exec_tool["inputSchema"]["properties"]["commands"]["maxItems"] >= 5 def test_call_query_ume_alarms_forwards_http() -> None: with patch("netx_mcp.http_tools.http_json") as mock_http: mock_http.return_value = {"ok": True, "data": {"total": 0, "items": []}} out = call_http_tool("queryUmeAlarms", {"severity": "critical", "page": 1, "page_size": 10}) mock_http.assert_called_once() assert mock_http.call_args[0][0] == "GET" assert mock_http.call_args[0][1] == "/v1/ume/alarms" params = mock_http.call_args[1]["params"] assert params["severity"] == "critical" text = out["content"][0]["text"] payload = json.loads(text) assert payload["ok"] is True def test_call_aggregate_ume_alarms_forwards_top_ne() -> None: with patch("netx_mcp.http_tools.http_json") as mock_http: mock_http.return_value = { "ok": True, "data": {"total": 10, "by_severity": [], "by_ne": [], "by_ne_total": 3, "top_ne": 20}, } out = call_http_tool("aggregateUmeAlarms", {"top_ne": 20, "severity": "critical"}) mock_http.assert_called_once_with( "GET", "/v1/ume/alarms/aggregate", params={"top_ne": 20, "severity": "critical"}, ) payload = json.loads(out["content"][0]["text"]) assert payload["ok"] is True assert payload["data"]["top_ne"] == 20 def test_call_aggregate_ume_alarms_group_by_routes_to_raw() -> None: with patch("netx_mcp.http_tools.http_json") as mock_http: mock_http.return_value = {"ok": True, "data": {"buckets": []}} out = call_http_tool( "aggregateUmeAlarms", {"group_by": "alarm_host_name", "severity": "critical", "limit": 20}, ) mock_http.assert_called_once() assert mock_http.call_args[0][0] == "GET" assert mock_http.call_args[0][1] == "/v1/ume/alarms/aggregate/raw" params = mock_http.call_args[1]["params"] assert params["group_by"] == "alarm_host_name" assert params["severity"] == "critical" assert params["limit"] == "20" payload = json.loads(out["content"][0]["text"]) assert payload["ok"] is True def test_aggregate_ume_alarms_schema_accepts_group_by() -> None: tool = next(t for t in HTTP_MCP_TOOLS if t.get("name") == "aggregateUmeAlarms") props = tool["inputSchema"]["properties"] assert "group_by" in props assert "group_by2" in props assert "alarm_host_name" in props["group_by"]["enum"] def test_call_find_topology_paths_defaults_summary_detail() -> None: with patch("netx_mcp.http_tools.http_post_json") as mock_post: mock_post.return_value = {"ok": True, "data": {"path_count": 1, "detail": "summary", "paths": []}} out = call_http_tool( "findTopologyPaths", {"from_ume_ne_id": "a", "to_ume_ne_id": "b"}, ) mock_post.assert_called_once() body = mock_post.call_args[0][1] assert body["detail"] == "summary" assert body["from_ume_ne_id"] == "a" assert body["to_ume_ne_id"] == "b" payload = json.loads(out["content"][0]["text"]) assert payload["ok"] is True def test_call_exec_managed_ne_defaults_read_timeout() -> None: with patch("netx_mcp.http_tools.http_post_json") as mock_post: mock_post.return_value = {"ok": True, "data": {"ok": True, "output": "hi"}} out = call_http_tool( "execManagedNe", {"ume_ne_id": "u1", "commands": ["show version"]}, ) mock_post.assert_called_once() body = mock_post.call_args[0][1] assert body["read_timeout_sec"] == 60 assert body["ume_ne_id"] == "u1" payload = json.loads(out["content"][0]["text"]) assert payload["ok"] is True def test_get_managed_ne_requires_id_with_hint() -> None: out = call_http_tool("getManagedNe", {}) assert out.get("isError") is True payload = json.loads(out["content"][0]["text"]) assert payload["error"] == "ne_id_required" assert "hint" in payload def test_get_managed_ne_accepts_managed_ne_id_alias() -> None: with patch("netx_mcp.http_tools.http_json") as mock_http: mock_http.return_value = {"ok": True, "data": {"ne_id": "m1"}} out = call_http_tool("getManagedNe", {"managed_ne_id": "m1"}) mock_http.assert_called_once() assert mock_http.call_args[0][1].endswith("/m1") payload = json.loads(out["content"][0]["text"]) assert payload["ok"] is True def test_call_get_ume_ne_requires_id() -> None: out = call_http_tool("getUmeNe", {}) assert out.get("isError") is True payload = json.loads(out["content"][0]["text"]) assert payload["error"] == "ne_id_required" def test_call_exec_managed_ne_posts_body() -> None: with patch("netx_mcp.http_tools.http_post_json") as mock_post: mock_post.return_value = {"ok": True, "data": {"ok": True, "output": "ok"}} out = call_http_tool( "execManagedNe", {"ne_id": "abc", "commands": ["show version"]}, ) mock_post.assert_called_once() assert mock_post.call_args[0][0] == "/v1/managed-ne/exec" body = mock_post.call_args[0][1] assert body["ne_id"] == "abc" assert body["commands"] == ["show version"] text = out["content"][0]["text"] payload = json.loads(text) assert payload["ok"] is True def test_call_exec_managed_ne_respects_max_commands_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("NETX_NE_EXEC_MAX_COMMANDS", "10") cmds = [f"show version {i}" for i in range(6)] with patch("netx_mcp.http_tools.http_post_json") as mock_post: mock_post.return_value = {"ok": True, "data": {"ok": True, "output": "ok"}} out = call_http_tool("execManagedNe", {"ne_id": "abc", "commands": cmds}) mock_post.assert_called_once() text = out["content"][0]["text"] payload = json.loads(text) assert payload["ok"] is True monkeypatch.setenv("NETX_NE_EXEC_MAX_COMMANDS", "5") out = call_http_tool("execManagedNe", {"ne_id": "abc", "commands": cmds}) text = out["content"][0]["text"] payload = json.loads(text) assert payload.get("ok") is False 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: import os env = os.environ.copy() env["NETX_API_URL"] = "http://127.0.0.1:1" env.pop("NETX_API_TOKEN", None) proc = subprocess.Popen( [sys.executable, "-m", "netx_mcp"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", env=env, ) assert proc.stdin and proc.stdout init_req = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + "\n" proc.stdin.write(init_req) proc.stdin.flush() init_line = proc.stdout.readline() init_resp = json.loads(init_line) assert init_resp["result"]["serverInfo"]["mode"] == "http" list_req = json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) + "\n" proc.stdin.write(list_req) 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) == 14 proc.terminate() proc.wait(timeout=5)