mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 09:43:58 +08:00
- netx_api/mcp_remote: ops / topology / bizMonitor tools, Bearer auth, per-tool RBAC filtering, ?groups= filter, readOnlyHint and group meta. - Mount the official MCP Streamable HTTP app at /mcp (stateless); AuthAuditMiddleware is pure ASGI and skips /mcp. - Let uvicorn chunk MCP responses; some Windows LSPs rewrite Content-Length replies on */mcp* paths into broken chunked framing. - NETX_MCP_ALLOWED_HOSTS opts into DNS-rebinding checks (off by default so LAN and reverse-proxy hosts work; Bearer is the gate). - Remove the three stdio Python MCP packages; tests moved to /mcp, including real-TCP framing regressions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
146 lines
4.7 KiB
Python
146 lines
4.7 KiB
Python
"""Unified MCP tool registry: schemas, scopes, groups, annotations."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Callable
|
|
|
|
from mcp.types import Tool, ToolAnnotations
|
|
|
|
from .bizmonitor.http_tools import (
|
|
HTTP_MCP_TOOLS as BIZ_TOOLS,
|
|
TOOL_REQUIRED_SCOPE as BIZ_SCOPES,
|
|
call_http_tool as call_biz_tool,
|
|
)
|
|
from .ops.http_tools import (
|
|
HTTP_MCP_TOOLS as OPS_TOOLS,
|
|
TOOL_REQUIRED_SCOPE as OPS_SCOPES,
|
|
call_http_tool as call_ops_tool,
|
|
)
|
|
from .topology.http_tools import (
|
|
HTTP_MCP_TOOLS as TOPO_TOOLS,
|
|
TOOL_REQUIRED_SCOPE as TOPO_SCOPES,
|
|
call_http_tool as call_topo_tool,
|
|
)
|
|
|
|
GROUP_OPS = "ops"
|
|
GROUP_TOPOLOGY = "topology"
|
|
GROUP_BIZ = "bizMonitor"
|
|
VALID_GROUPS = frozenset({GROUP_OPS, GROUP_TOPOLOGY, GROUP_BIZ})
|
|
|
|
# Write / destructive hints (task contract).
|
|
_OPS_WRITE = frozenset({"execManagedNe"})
|
|
_TOPO_WRITE = frozenset(
|
|
{
|
|
"createTopologyFolder",
|
|
"addTopologyViewNodes",
|
|
"removeTopologyViewNodes",
|
|
"sinkTopologyDualUnits",
|
|
"copyTopologyViewNodes",
|
|
"updateTopologyViewPositions",
|
|
"projectTopologyNeighbors",
|
|
"classifyTopologyFabricNodes",
|
|
"layoutTopologyView",
|
|
}
|
|
)
|
|
_TOPO_DESTRUCTIVE = frozenset({"removeTopologyViewNodes"})
|
|
|
|
|
|
def _enrich(tool: dict[str, Any], *, group: str) -> dict[str, Any]:
|
|
name = str(tool.get("name") or "")
|
|
out = dict(tool)
|
|
if group == GROUP_OPS:
|
|
read_only = name not in _OPS_WRITE
|
|
destructive = False
|
|
elif group == GROUP_TOPOLOGY:
|
|
read_only = name not in _TOPO_WRITE
|
|
destructive = name in _TOPO_DESTRUCTIVE
|
|
else:
|
|
read_only = True
|
|
destructive = False
|
|
annotations = dict(out.get("annotations") or {})
|
|
annotations.setdefault("readOnlyHint", read_only)
|
|
annotations.setdefault("destructiveHint", destructive)
|
|
if read_only:
|
|
annotations.setdefault("idempotentHint", True)
|
|
out["annotations"] = annotations
|
|
meta = dict(out.get("_meta") or out.get("meta") or {})
|
|
netx = dict(meta.get("netx") or {})
|
|
netx["group"] = group
|
|
meta["netx"] = netx
|
|
out["_meta"] = meta
|
|
out["_group"] = group
|
|
return out
|
|
|
|
|
|
ALL_TOOLS: list[dict[str, Any]] = (
|
|
[_enrich(t, group=GROUP_OPS) for t in OPS_TOOLS]
|
|
+ [_enrich(t, group=GROUP_TOPOLOGY) for t in TOPO_TOOLS]
|
|
+ [_enrich(t, group=GROUP_BIZ) for t in BIZ_TOOLS]
|
|
)
|
|
|
|
TOOL_REQUIRED_SCOPE: dict[str, str] = {}
|
|
TOOL_REQUIRED_SCOPE.update(OPS_SCOPES)
|
|
TOOL_REQUIRED_SCOPE.update(TOPO_SCOPES)
|
|
TOOL_REQUIRED_SCOPE.update(BIZ_SCOPES)
|
|
|
|
TOOL_GROUP: dict[str, str] = {str(t["name"]): str(t["_group"]) for t in ALL_TOOLS}
|
|
|
|
_HANDLERS: dict[str, Callable[[str, dict[str, Any]], dict[str, Any]]] = {
|
|
**{str(t["name"]): call_ops_tool for t in OPS_TOOLS},
|
|
**{str(t["name"]): call_topo_tool for t in TOPO_TOOLS},
|
|
**{str(t["name"]): call_biz_tool for t in BIZ_TOOLS},
|
|
}
|
|
|
|
|
|
def parse_groups_param(raw: str | None) -> frozenset[str]:
|
|
if raw is None or not str(raw).strip():
|
|
return frozenset()
|
|
parts = {p.strip() for p in str(raw).split(",") if p.strip()}
|
|
return frozenset(p for p in parts if p in VALID_GROUPS)
|
|
|
|
|
|
def tools_for_request(
|
|
scopes: frozenset[str] | list[str] | None,
|
|
groups: frozenset[str] | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""Filter by scopes and optional groups. scopes=None advertises all (offline)."""
|
|
wanted_groups = groups or frozenset()
|
|
if scopes is None:
|
|
scoped = list(ALL_TOOLS)
|
|
else:
|
|
granted = {str(s).strip().lower() for s in scopes if str(s).strip()}
|
|
if not granted:
|
|
return []
|
|
scoped = []
|
|
for tool in ALL_TOOLS:
|
|
name = str(tool.get("name") or "")
|
|
need = TOOL_REQUIRED_SCOPE.get(name)
|
|
if need is None or need in granted:
|
|
scoped.append(tool)
|
|
if not wanted_groups:
|
|
return scoped
|
|
return [t for t in scoped if t.get("_group") in wanted_groups]
|
|
|
|
|
|
def to_mcp_tool(tool: dict[str, Any]) -> Tool:
|
|
ann = tool.get("annotations") or {}
|
|
return Tool(
|
|
name=str(tool["name"]),
|
|
description=str(tool.get("description") or ""),
|
|
input_schema=tool.get("inputSchema") or {"type": "object", "properties": {}},
|
|
annotations=ToolAnnotations(
|
|
read_only_hint=bool(ann.get("readOnlyHint", True)),
|
|
destructive_hint=bool(ann.get("destructiveHint", False))
|
|
if not ann.get("readOnlyHint", True)
|
|
else None,
|
|
idempotent_hint=ann.get("idempotentHint"),
|
|
),
|
|
meta=tool.get("_meta") or tool.get("meta"),
|
|
)
|
|
|
|
|
|
def call_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
|
|
handler = _HANDLERS.get(str(name or "").strip())
|
|
if handler is None:
|
|
raise ValueError(f"unknown tool: {name}")
|
|
return handler(name, args)
|