MCP: serve tools from the API process over Streamable HTTP at /mcp

- 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>
This commit is contained in:
oliver 2026-10-16 21:52:08 +08:00
parent 918ece86c3
commit 52679a7cfc
150 changed files with 2367 additions and 2812 deletions

View file

@ -0,0 +1,146 @@
"""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)