netx/packages/netx-mcp/src/netx_mcp/server.py
oliver 13ce2482ab Add shared skills tree and rename MCP NMS tools to *Nms*.
Canonical playbooks live under skills/; netx-mcp 0.3.0 exposes queryNmsAlarms et al. with nms_ne_id aliases.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 01:46:21 +08:00

132 lines
4.3 KiB
Python

"""netx stdio MCP server (HTTP client to netx REST API).
Environment:
- ``NETX_API_URL``: netx REST base URL (default ``http://127.0.0.1:8890``)
- ``NETX_API_TOKEN``: optional Bearer token
- ``NETX_LANG``: ``zh`` or ``en`` for localized API responses
"""
from __future__ import annotations
import json
import sys
from typing import Any
from netx_mcp.http_client import http_json
from netx_mcp.http_tools import TOOL_REQUIRED_SCOPE, call_http_tool, tools_for_scopes
def _ensure_utf8_stdio() -> None:
"""MCP stdio must be UTF-8; Windows defaults to GBK and breaks the host reader."""
for stream in (sys.stdin, sys.stdout, sys.stderr):
if stream is None or not hasattr(stream, "reconfigure"):
continue
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
def _ok(rid: Any, result: dict[str, Any]) -> None:
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}, ensure_ascii=False) + "\n")
sys.stdout.flush()
def _err(rid: Any, code: int, message: str) -> None:
sys.stdout.write(
json.dumps({"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}, ensure_ascii=False)
+ "\n"
)
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:
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(user, dict) and isinstance(user.get("scopes"), list):
return [str(s) for s in user["scopes"]]
except Exception:
return None
return None
def run_stdio_loop() -> None:
cached_scopes: list[str] | None | object = _UNSET
def scopes() -> list[str] | None:
nonlocal cached_scopes
if cached_scopes is _UNSET:
cached_scopes = _fetch_scopes()
return cached_scopes # type: ignore[return-value]
for line in sys.stdin:
raw = line.strip()
if not raw:
continue
try:
req = json.loads(raw)
except Exception:
continue
rid = req.get("id")
method = str(req.get("method") or "")
params = req.get("params") if isinstance(req.get("params"), dict) else {}
try:
if method == "initialize":
_ok(
rid,
{
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "netx-mcp", "version": "0.3.0", "mode": "http"},
},
)
continue
if method == "notifications/initialized":
continue
if method == "tools/list":
_ok(rid, {"tools": tools_for_scopes(scopes())})
continue
if method == "tools/call":
name = str(params.get("name") or "")
need = TOOL_REQUIRED_SCOPE.get(name)
granted = scopes()
if need and granted is not None and need not in {str(s).lower() for s in granted}:
_err(
rid,
-32001,
(
f"insufficient_scope:{need}. "
"Ask a netx admin to grant this scope on the API token; "
"for sql:query prefer aggregateNmsAlarms/queryNmsAlarmsRaw instead."
),
)
continue
args = params.get("arguments") if isinstance(params.get("arguments"), dict) else {}
_ok(rid, call_http_tool(name, args))
continue
_err(rid, -32601, f"method not found: {method}")
except Exception as exc:
_err(rid, -32000, str(exc))
def main() -> None:
_ensure_utf8_stdio()
run_stdio_loop()
if __name__ == "__main__":
main()