mirror of
https://github.com/hansjone/netx.git
synced 2026-10-10 19:20:46 +08:00
feat(mcp): add netx-mcp package and HTTP stdio MCP
- Extract installable packages/netx-mcp (12 HTTP tools, mcp.json) - Delegate netx_api.mcp to netx_mcp; keep legacy db_server shim - Add docs/MCP.md, install payload, pyproject entry, tests Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
981347b5b1
commit
45ce9d3f7e
23 changed files with 1810 additions and 556 deletions
5
packages/netx-mcp/src/netx_mcp/__init__.py
Normal file
5
packages/netx-mcp/src/netx_mcp/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""netx-mcp: stdio MCP → netx HTTP API."""
|
||||
|
||||
from .http_tools import HTTP_MCP_TOOLS, call_http_tool
|
||||
|
||||
__all__ = ["HTTP_MCP_TOOLS", "call_http_tool"]
|
||||
4
packages/netx-mcp/src/netx_mcp/__main__.py
Normal file
4
packages/netx-mcp/src/netx_mcp/__main__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from netx_mcp.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
107
packages/netx-mcp/src/netx_mcp/http_client.py
Normal file
107
packages/netx-mcp/src/netx_mcp/http_client.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"""HTTP client for netx REST API (used by stdio MCP server)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
_PROTOCOL_KEY_ZH_TO_EN: dict[str, str] = {
|
||||
"其他": "Other",
|
||||
"时钟": "Clock",
|
||||
"OTN/光": "OTN/Optical",
|
||||
"电源": "Power",
|
||||
}
|
||||
|
||||
|
||||
def api_base_url() -> str:
|
||||
raw = (
|
||||
os.getenv("NETX_API_URL")
|
||||
or os.getenv("OCLAW_NETX_BASE_URL")
|
||||
or "http://127.0.0.1:8890"
|
||||
)
|
||||
return str(raw or "").strip().rstrip("/")
|
||||
|
||||
|
||||
def api_headers() -> dict[str, str]:
|
||||
h = {"accept": "application/json"}
|
||||
tok = (os.getenv("NETX_API_TOKEN") or os.getenv("OCLAW_NETX_API_TOKEN") or "").strip()
|
||||
if tok:
|
||||
h["authorization"] = f"Bearer {tok}"
|
||||
return h
|
||||
|
||||
|
||||
def lang_query_params() -> dict[str, str]:
|
||||
lang = str(os.getenv("NETX_LANG") or "zh").strip().lower()
|
||||
if lang.startswith("en"):
|
||||
return {"lang": "en"}
|
||||
return {}
|
||||
|
||||
|
||||
def localize_payload(data: dict[str, Any]) -> dict[str, Any]:
|
||||
lang = str(os.getenv("NETX_LANG") or "zh").strip().lower()
|
||||
if not lang.startswith("en"):
|
||||
return data
|
||||
proto = data.get("protocol_summary")
|
||||
if isinstance(proto, list):
|
||||
for row in proto:
|
||||
if isinstance(row, dict):
|
||||
k = str(row.get("key") or "")
|
||||
if k in _PROTOCOL_KEY_ZH_TO_EN:
|
||||
row["key"] = _PROTOCOL_KEY_ZH_TO_EN[k]
|
||||
return data
|
||||
|
||||
|
||||
def http_json(method: str, path: str, *, params: dict[str, Any] | None = None, timeout: float = 45.0) -> dict[str, Any]:
|
||||
url = f"{api_base_url()}{path}"
|
||||
merged: dict[str, Any] = dict(lang_query_params())
|
||||
if params:
|
||||
merged.update(params)
|
||||
try:
|
||||
with httpx.Client(timeout=timeout, trust_env=False) as client:
|
||||
resp = client.request(method, url, params=merged or None, headers=api_headers())
|
||||
text = resp.text
|
||||
if not resp.is_success:
|
||||
return {"ok": False, "error": f"netx_http_{resp.status_code}", "detail": text[:800]}
|
||||
data = resp.json() if text else {}
|
||||
if isinstance(data, dict):
|
||||
data = localize_payload(data)
|
||||
return {"ok": True, "data": data if isinstance(data, dict) else {"raw": data}}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": "netx_request_failed", "detail": str(exc)[:800]}
|
||||
|
||||
|
||||
def http_post_json(path: str, body: dict[str, Any], *, timeout: float = 180.0) -> dict[str, Any]:
|
||||
url = f"{api_base_url()}{path}"
|
||||
try:
|
||||
with httpx.Client(timeout=timeout, trust_env=False) as client:
|
||||
resp = client.post(url, json=body, headers=api_headers())
|
||||
text = resp.text
|
||||
if not resp.is_success:
|
||||
return {"ok": False, "error": f"netx_http_{resp.status_code}", "detail": text[:800]}
|
||||
data = resp.json() if text else {}
|
||||
if isinstance(data, dict):
|
||||
data = localize_payload(data)
|
||||
return {"ok": True, "data": data if isinstance(data, dict) else {"raw": data}}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": "netx_request_failed", "detail": str(exc)[:800]}
|
||||
|
||||
|
||||
def mcp_text_result(payload: Any, *, is_error: bool = False) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
|
||||
if is_error:
|
||||
out["isError"] = True
|
||||
return out
|
||||
|
||||
|
||||
def mcp_from_handler_result(result: dict[str, Any]) -> dict[str, Any]:
|
||||
if not result.get("ok"):
|
||||
return mcp_text_result(result, is_error=True)
|
||||
return mcp_text_result(result)
|
||||
|
||||
|
||||
def quote_ne_id(ne_id: str) -> str:
|
||||
return quote(str(ne_id or "").strip(), safe="")
|
||||
428
packages/netx-mcp/src/netx_mcp/http_tools.py
Normal file
428
packages/netx-mcp/src/netx_mcp/http_tools.py
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
"""MCP tool schemas and HTTP-backed handlers (12 tools: UME + managed NE; no Excel import batch)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from .http_client import http_json, http_post_json, mcp_from_handler_result, quote_ne_id
|
||||
|
||||
UME_RAW_GROUP_FIELDS = [
|
||||
"alarm_alarm_key",
|
||||
"alarm_host_name",
|
||||
"alarm_ne_id",
|
||||
"alarm_object_name",
|
||||
"alarm_event_type",
|
||||
"alarm_native_probable_cause",
|
||||
"alarm_perceived_severity",
|
||||
"alarm_is_cleared",
|
||||
"alarm_time_created",
|
||||
"alarm_root_cause_alarm_indication",
|
||||
"ne_ne_id",
|
||||
"ne_ne_name",
|
||||
"ne_user_label",
|
||||
"ne_ip_address",
|
||||
"ne_ipv6_address",
|
||||
"ne_ne_type",
|
||||
"ne_device_level",
|
||||
"ne_host_name",
|
||||
"ne_location",
|
||||
"ne_hardware_version",
|
||||
"ne_loopback",
|
||||
"ne_consistent_state",
|
||||
"ne_interface_version",
|
||||
"ne_mac",
|
||||
"ne_admin_status",
|
||||
"ne_address_type",
|
||||
"ne_connection_status",
|
||||
"ne_maintain_status",
|
||||
"ne_net_mask",
|
||||
"ne_create_time",
|
||||
"ne_creator",
|
||||
"ne_vendor",
|
||||
"ne_source_type",
|
||||
"ne_exists",
|
||||
]
|
||||
|
||||
_UME_RAW_FIELD_PRESETS: dict[str, list[str]] = {
|
||||
"brief": [
|
||||
"alarm_alarm_key",
|
||||
"alarm_host_name",
|
||||
"alarm_perceived_severity",
|
||||
"alarm_event_type",
|
||||
"alarm_last_seen_at",
|
||||
"ne_host_name",
|
||||
"ne_user_label",
|
||||
"ne_ne_name",
|
||||
"ne_ip_address",
|
||||
"ne_exists",
|
||||
],
|
||||
"evidence": [
|
||||
"alarm_alarm_key",
|
||||
"alarm_host_name",
|
||||
"alarm_object_name",
|
||||
"alarm_event_type",
|
||||
"alarm_native_probable_cause",
|
||||
"alarm_perceived_severity",
|
||||
"alarm_is_cleared",
|
||||
"alarm_time_created",
|
||||
"alarm_last_seen_at",
|
||||
"ne_host_name",
|
||||
"ne_user_label",
|
||||
"ne_ne_name",
|
||||
"ne_ip_address",
|
||||
"ne_connection_status",
|
||||
"ne_exists",
|
||||
],
|
||||
"ne_debug": [
|
||||
"alarm_alarm_key",
|
||||
"alarm_ne_id",
|
||||
"alarm_perceived_severity",
|
||||
"alarm_last_seen_at",
|
||||
"ne_user_label",
|
||||
"ne_ne_name",
|
||||
"ne_ip_address",
|
||||
"ne_ipv6_address",
|
||||
"ne_device_level",
|
||||
"ne_host_name",
|
||||
"ne_connection_status",
|
||||
"ne_admin_status",
|
||||
"ne_address_type",
|
||||
"ne_maintain_status",
|
||||
"ne_exists",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _query_ume_alarms(args: dict[str, Any]) -> dict[str, Any]:
|
||||
page = max(1, int(args.get("page") or 1))
|
||||
if page > 2:
|
||||
page = 2
|
||||
page_size = min(500, max(1, int(args.get("page_size") or 50)))
|
||||
params: dict[str, Any] = {"page": page, "page_size": page_size}
|
||||
if str(args.get("severity") or "").strip():
|
||||
params["severity"] = str(args.get("severity")).strip()
|
||||
keyword = str(args.get("keyword") or "").strip()
|
||||
ne_name = str(args.get("ne_name") or "").strip()
|
||||
if keyword:
|
||||
params["keyword"] = keyword
|
||||
elif ne_name:
|
||||
params["keyword"] = ne_name
|
||||
if str(args.get("ne_id") or "").strip():
|
||||
params["ne_id"] = str(args.get("ne_id")).strip()
|
||||
return http_json("GET", "/v1/ume/alarms", params=params)
|
||||
|
||||
|
||||
def _aggregate_ume_alarms(args: dict[str, Any]) -> dict[str, Any]:
|
||||
_ = args
|
||||
return http_json("GET", "/v1/ume/alarms/aggregate", params=None)
|
||||
|
||||
|
||||
def _run_ume_diagnostics(args: dict[str, Any]) -> dict[str, Any]:
|
||||
_ = args
|
||||
return http_json("GET", "/v1/ume/diagnostics", params=None)
|
||||
|
||||
|
||||
def _query_ume_ne_inventory(args: dict[str, Any]) -> dict[str, Any]:
|
||||
page = max(1, int(args.get("page") or 1))
|
||||
page_size = min(500, max(1, int(args.get("page_size") or 50)))
|
||||
params: dict[str, Any] = {"page": page, "page_size": page_size}
|
||||
if str(args.get("keyword") or "").strip():
|
||||
params["keyword"] = str(args.get("keyword")).strip()
|
||||
return http_json("GET", "/v1/ume/inventory/ne", params=params)
|
||||
|
||||
|
||||
def _get_ume_ne(args: dict[str, Any]) -> dict[str, Any]:
|
||||
ne_id = str(args.get("ne_id") or "").strip()
|
||||
if not ne_id:
|
||||
return {"ok": False, "error": "ne_id_required", "error_code": "ne_id_required"}
|
||||
return http_json("GET", f"/v1/ume/inventory/ne/{quote_ne_id(ne_id)}", params=None)
|
||||
|
||||
|
||||
def _query_ume_alarms_raw(args: dict[str, Any]) -> dict[str, Any]:
|
||||
page = max(1, int(args.get("page") or 1))
|
||||
page_size = min(500, max(1, int(args.get("page_size") or 50)))
|
||||
params: dict[str, Any] = {"page": page, "page_size": page_size}
|
||||
for k in ("severity", "is_cleared", "ne_id", "event_type", "keyword", "time_from", "time_to", "order_by", "order"):
|
||||
v = str(args.get(k) or "").strip()
|
||||
if v:
|
||||
params[k] = v
|
||||
sf = args.get("select_fields")
|
||||
fields: list[str] = []
|
||||
if isinstance(sf, list):
|
||||
fields = [str(x).strip() for x in sf if str(x).strip()]
|
||||
if not fields:
|
||||
preset = str(args.get("field_preset") or "").strip().lower()
|
||||
fields = list(_UME_RAW_FIELD_PRESETS.get(preset) or [])
|
||||
if fields:
|
||||
params["select_fields"] = ",".join(fields)
|
||||
return http_json("GET", "/v1/ume/alarms/raw", params=params)
|
||||
|
||||
|
||||
def _aggregate_ume_alarms_raw(args: dict[str, Any]) -> dict[str, Any]:
|
||||
params: dict[str, Any] = {}
|
||||
for k in (
|
||||
"group_by",
|
||||
"group_by2",
|
||||
"severity",
|
||||
"is_cleared",
|
||||
"ne_id",
|
||||
"event_type",
|
||||
"keyword",
|
||||
"time_from",
|
||||
"time_to",
|
||||
"limit",
|
||||
):
|
||||
v = args.get(k)
|
||||
if v is None:
|
||||
continue
|
||||
sv = str(v).strip()
|
||||
if sv:
|
||||
params[k] = sv
|
||||
return http_json("GET", "/v1/ume/alarms/aggregate/raw", params=params)
|
||||
|
||||
|
||||
def _list_ume_alarm_fields(args: dict[str, Any]) -> dict[str, Any]:
|
||||
_ = args
|
||||
return http_json("GET", "/v1/ume/alarms/fields", params=None)
|
||||
|
||||
|
||||
def _sql_query_ume(args: dict[str, Any]) -> dict[str, Any]:
|
||||
sql = str(args.get("sql") or "").strip()
|
||||
limit = max(1, min(2000, int(args.get("limit") or 200)))
|
||||
statement_timeout_ms = max(0, min(30000, int(args.get("statement_timeout_ms") or 0)))
|
||||
if not sql:
|
||||
return {"ok": False, "error": "sql_required"}
|
||||
return http_post_json(
|
||||
"/v1/sql/ume_query",
|
||||
{"sql": sql, "limit": limit, "statement_timeout_ms": statement_timeout_ms},
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
|
||||
def _list_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
||||
page = max(1, int(args.get("page") or 1))
|
||||
page_size = min(500, max(1, int(args.get("page_size") or 50)))
|
||||
params: dict[str, Any] = {"page": page, "page_size": page_size}
|
||||
if str(args.get("keyword") or "").strip():
|
||||
params["keyword"] = str(args.get("keyword")).strip()
|
||||
if str(args.get("vendor") or "").strip():
|
||||
params["vendor"] = str(args.get("vendor")).strip()
|
||||
if str(args.get("connect_status") or "").strip():
|
||||
params["connect_status"] = str(args.get("connect_status")).strip()
|
||||
return http_json("GET", "/v1/managed-ne", params=params)
|
||||
|
||||
|
||||
def _get_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
||||
ne_id = str(args.get("ne_id") or "").strip()
|
||||
if not ne_id:
|
||||
return {"ok": False, "error": "ne_id_required", "error_code": "ne_id_required"}
|
||||
return http_json("GET", f"/v1/managed-ne/{ne_id}", params=None)
|
||||
|
||||
|
||||
def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
||||
ne_id = str(args.get("ne_id") or "").strip()
|
||||
if not ne_id:
|
||||
return {"ok": False, "error": "ne_id_required", "error_code": "ne_id_required"}
|
||||
raw_cmds = args.get("commands")
|
||||
if not isinstance(raw_cmds, list) or not raw_cmds:
|
||||
return {"ok": False, "error": "commands_required", "error_code": "commands_required"}
|
||||
commands = [str(c).strip() for c in raw_cmds if str(c).strip()]
|
||||
if not commands:
|
||||
return {"ok": False, "error": "commands_required", "error_code": "commands_required"}
|
||||
if len(commands) > 5:
|
||||
return {"ok": False, "error": "too_many_commands", "error_code": "too_many_commands"}
|
||||
body: dict[str, Any] = {"ne_id": ne_id, "commands": commands}
|
||||
rts = args.get("read_timeout_sec")
|
||||
if rts is not None:
|
||||
body["read_timeout_sec"] = int(rts)
|
||||
out = http_post_json("/v1/managed-ne/exec", body, timeout=300.0)
|
||||
if not out.get("ok"):
|
||||
return out
|
||||
data = out.get("data") or {}
|
||||
if isinstance(data, dict) and data.get("ok") is False:
|
||||
return {"ok": False, "data": data, "error": str(data.get("error") or "exec_failed")}
|
||||
return {"ok": True, "data": data}
|
||||
|
||||
|
||||
HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
||||
{
|
||||
"name": "queryUmeAlarms",
|
||||
"description": "Query UME current alarms (each row includes host_name). Supports severity/ne_id/keyword and pagination.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"severity": {"type": "string"},
|
||||
"ne_id": {"type": "string"},
|
||||
"ne_name": {"type": "string", "description": "Legacy alias mapped to keyword"},
|
||||
"keyword": {"type": "string"},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "aggregateUmeAlarms",
|
||||
"description": "Aggregate UME current alarms (by_severity/by_ne).",
|
||||
"inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
|
||||
},
|
||||
{
|
||||
"name": "runUmeDiagnostics",
|
||||
"description": "UME alarm diagnostics summary (severity distribution, top codes/NEs, protocol buckets).",
|
||||
"inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
|
||||
},
|
||||
{
|
||||
"name": "queryUmeNeInventory",
|
||||
"description": "Paged UME NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"keyword": {"type": "string"},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "getUmeNe",
|
||||
"description": "Get single UME NE detail by ne_id (UUID).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"ne_id": {"type": "string"}},
|
||||
"required": ["ne_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "queryUmeAlarmsRaw",
|
||||
"description": "Power query UME current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"severity": {"type": "string"},
|
||||
"is_cleared": {"type": "string"},
|
||||
"ne_id": {"type": "string"},
|
||||
"event_type": {"type": "string"},
|
||||
"keyword": {"type": "string"},
|
||||
"time_from": {"type": "string"},
|
||||
"time_to": {"type": "string"},
|
||||
"order_by": {
|
||||
"type": "string",
|
||||
"enum": ["last_seen_at", "time_created", "perceived_severity", "event_type", "ne_id"],
|
||||
},
|
||||
"order": {"type": "string", "enum": ["asc", "desc"]},
|
||||
"select_fields": {"type": "array", "items": {"type": "string"}},
|
||||
"field_preset": {"type": "string", "enum": ["brief", "evidence", "ne_debug"]},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "aggregateUmeAlarmsRaw",
|
||||
"description": "Dynamic aggregation on UME raw fields (group_by/group_by2); prefer alarm_host_name for NE grouping.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"group_by": {"type": "string", "enum": UME_RAW_GROUP_FIELDS},
|
||||
"group_by2": {"type": "string", "enum": UME_RAW_GROUP_FIELDS},
|
||||
"severity": {"type": "string"},
|
||||
"is_cleared": {"type": "string"},
|
||||
"ne_id": {"type": "string"},
|
||||
"event_type": {"type": "string"},
|
||||
"keyword": {"type": "string"},
|
||||
"time_from": {"type": "string"},
|
||||
"time_to": {"type": "string"},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 2000, "default": 200},
|
||||
},
|
||||
"required": ["group_by"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "listUmeAlarmFields",
|
||||
"description": "List available fields for UME raw alarm queries.",
|
||||
"inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
|
||||
},
|
||||
{
|
||||
"name": "sqlQueryUme",
|
||||
"description": "Read-only SELECT on UME tables (ume_alarms_current/ume_inventory_ne); server enforces limits.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sql": {"type": "string"},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 2000, "default": 200},
|
||||
"statement_timeout_ms": {"type": "integer", "minimum": 0, "maximum": 30000, "default": 0},
|
||||
},
|
||||
"required": ["sql"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "listManagedNe",
|
||||
"description": "List netx managed NEs (SSH/Telnet inventory); use before execManagedNe.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"keyword": {"type": "string"},
|
||||
"vendor": {"type": "string"},
|
||||
"connect_status": {"type": "string", "enum": ["unknown", "testing", "pass", "fail"]},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "getManagedNe",
|
||||
"description": "Get single managed NE metadata (connect_status, hop config summary).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"ne_id": {"type": "string"}},
|
||||
"required": ["ne_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "execManagedNe",
|
||||
"description": "Run read-only CLI on a managed NE via netx (show/display/ping; max 5 commands).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ne_id": {"type": "string"},
|
||||
"commands": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 5},
|
||||
"read_timeout_sec": {"type": "integer", "minimum": 10, "maximum": 120},
|
||||
},
|
||||
"required": ["ne_id", "commands"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
_HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
|
||||
"queryUmeAlarms": _query_ume_alarms,
|
||||
"aggregateUmeAlarms": _aggregate_ume_alarms,
|
||||
"runUmeDiagnostics": _run_ume_diagnostics,
|
||||
"queryUmeNeInventory": _query_ume_ne_inventory,
|
||||
"getUmeNe": _get_ume_ne,
|
||||
"queryUmeAlarmsRaw": _query_ume_alarms_raw,
|
||||
"aggregateUmeAlarmsRaw": _aggregate_ume_alarms_raw,
|
||||
"listUmeAlarmFields": _list_ume_alarm_fields,
|
||||
"sqlQueryUme": _sql_query_ume,
|
||||
"listManagedNe": _list_managed_ne,
|
||||
"getManagedNe": _get_managed_ne,
|
||||
"execManagedNe": _exec_managed_ne,
|
||||
}
|
||||
|
||||
|
||||
def call_http_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
fn = _HANDLERS.get(str(name or "").strip())
|
||||
if not fn:
|
||||
raise ValueError(f"unknown tool: {name}")
|
||||
return mcp_from_handler_result(fn(dict(args or {})))
|
||||
87
packages/netx-mcp/src/netx_mcp/server.py
Normal file
87
packages/netx-mcp/src/netx_mcp/server.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""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_tools import HTTP_MCP_TOOLS, call_http_tool
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def run_stdio_loop() -> None:
|
||||
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.2.0", "mode": "http"},
|
||||
},
|
||||
)
|
||||
continue
|
||||
if method == "notifications/initialized":
|
||||
continue
|
||||
if method == "tools/list":
|
||||
_ok(rid, {"tools": HTTP_MCP_TOOLS})
|
||||
continue
|
||||
if method == "tools/call":
|
||||
name = str(params.get("name") or "")
|
||||
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue