mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 00:23:19 +08:00
Also make the HTTP-error test independent of the developer .env token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
238 lines
7.4 KiB
Python
238 lines
7.4 KiB
Python
"""Low-level MCP Server exposing ops/topology/bizMonitor tools over Streamable HTTP."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from importlib.metadata import version as pkg_version
|
|
from typing import Any
|
|
|
|
from mcp.server import Server, ServerRequestContext
|
|
from mcp.shared.exceptions import MCPError
|
|
from mcp.types import (
|
|
CallToolRequestParams,
|
|
CallToolResult,
|
|
GetPromptRequestParams,
|
|
GetPromptResult,
|
|
INVALID_PARAMS,
|
|
ListPromptsResult,
|
|
ListResourcesResult,
|
|
ListToolsResult,
|
|
PaginatedRequestParams,
|
|
Prompt,
|
|
PromptMessage,
|
|
ReadResourceRequestParams,
|
|
ReadResourceResult,
|
|
Resource,
|
|
TextContent,
|
|
TextResourceContents,
|
|
)
|
|
|
|
from .context import get_request_context
|
|
from .registry import TOOL_REQUIRED_SCOPE, call_tool, to_mcp_tool, tools_for_request
|
|
from .skills import (
|
|
build_instructions,
|
|
list_visible_skill_files,
|
|
list_visible_skills,
|
|
prompt_skill_md_text,
|
|
read_skill_text,
|
|
resolve_uri,
|
|
resource_dict,
|
|
)
|
|
|
|
|
|
def netx_version() -> str:
|
|
try:
|
|
return pkg_version("netx-ops")
|
|
except Exception:
|
|
pass
|
|
try:
|
|
from pathlib import Path
|
|
|
|
import tomllib
|
|
|
|
root = Path(__file__).resolve().parents[2]
|
|
data = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
|
|
return str(data.get("project", {}).get("version") or "0.0.0")
|
|
except Exception:
|
|
return "0.0.0"
|
|
|
|
|
|
def _scopes_and_groups() -> tuple[frozenset[str] | None, frozenset[str]]:
|
|
ctx = get_request_context()
|
|
if ctx is None:
|
|
return None, frozenset()
|
|
return ctx.scopes, ctx.groups
|
|
|
|
|
|
async def _on_list_tools(
|
|
ctx: ServerRequestContext,
|
|
params: PaginatedRequestParams | None,
|
|
) -> ListToolsResult:
|
|
scopes, groups = _scopes_and_groups()
|
|
tools = [to_mcp_tool(t) for t in tools_for_request(scopes, groups)]
|
|
return ListToolsResult(tools=tools)
|
|
|
|
|
|
async def _on_call_tool(
|
|
ctx: ServerRequestContext,
|
|
params: CallToolRequestParams,
|
|
) -> CallToolResult:
|
|
name = str(params.name or "")
|
|
scopes, groups = _scopes_and_groups()
|
|
advertised = {str(t["name"]) for t in tools_for_request(scopes, groups)}
|
|
if name not in advertised:
|
|
need = TOOL_REQUIRED_SCOPE.get(name)
|
|
msg = (
|
|
f"insufficient_scope:{need}. "
|
|
"Ask a netx admin to grant this scope on the API token; "
|
|
"for sql:query prefer aggregateNmsAlarms/queryNmsAlarmsRaw instead."
|
|
if need
|
|
else f"unknown or filtered tool: {name}"
|
|
)
|
|
return CallToolResult(
|
|
content=[TextContent(type="text", text=msg)],
|
|
is_error=True,
|
|
)
|
|
|
|
args = params.arguments if isinstance(params.arguments, dict) else {}
|
|
try:
|
|
# Handlers are sync and may nest ASGI calls; run off the event loop thread
|
|
# so BaseHTTPMiddleware on the outer MCP request cannot deadlock.
|
|
import asyncio
|
|
|
|
import anyio
|
|
|
|
from .asgi_client import bind_main_loop
|
|
|
|
bind_main_loop(asyncio.get_running_loop())
|
|
result = await anyio.to_thread.run_sync(call_tool, name, dict(args))
|
|
except Exception as exc:
|
|
result = {
|
|
"content": [{"type": "text", "text": str(exc)}],
|
|
"isError": True,
|
|
}
|
|
|
|
content_raw = result.get("content") if isinstance(result, dict) else None
|
|
texts: list[TextContent] = []
|
|
if isinstance(content_raw, list):
|
|
for item in content_raw:
|
|
if isinstance(item, dict) and item.get("type") == "text":
|
|
texts.append(TextContent(type="text", text=str(item.get("text") or "")))
|
|
if not texts:
|
|
texts = [TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
|
|
is_error = bool(isinstance(result, dict) and result.get("isError"))
|
|
return CallToolResult(content=texts, is_error=is_error)
|
|
|
|
|
|
async def _on_list_resources(
|
|
ctx: ServerRequestContext,
|
|
params: PaginatedRequestParams | None,
|
|
) -> ListResourcesResult:
|
|
scopes, groups = _scopes_and_groups()
|
|
resources: list[Resource] = []
|
|
for sf in list_visible_skill_files(scopes, groups):
|
|
d = resource_dict(sf, scopes=scopes)
|
|
resources.append(
|
|
Resource(
|
|
uri=d["uri"],
|
|
name=d["name"],
|
|
mimeType=d.get("mimeType"),
|
|
description=d.get("description"),
|
|
meta=d.get("_meta"),
|
|
)
|
|
)
|
|
return ListResourcesResult(resources=resources)
|
|
|
|
|
|
async def _on_read_resource(
|
|
ctx: ServerRequestContext,
|
|
params: ReadResourceRequestParams,
|
|
) -> ReadResourceResult:
|
|
scopes, groups = _scopes_and_groups()
|
|
uri = str(params.uri or "")
|
|
sf = resolve_uri(uri, scopes, groups)
|
|
if sf is None:
|
|
raise MCPError(INVALID_PARAMS, "resource not found or not authorized")
|
|
try:
|
|
text = read_skill_text(sf)
|
|
except OSError:
|
|
raise MCPError(INVALID_PARAMS, "resource not found or not authorized") from None
|
|
return ReadResourceResult(
|
|
contents=[
|
|
TextResourceContents(
|
|
uri=sf.uri,
|
|
mimeType="text/markdown",
|
|
text=text,
|
|
)
|
|
]
|
|
)
|
|
|
|
|
|
async def _on_list_prompts(
|
|
ctx: ServerRequestContext,
|
|
params: PaginatedRequestParams | None,
|
|
) -> ListPromptsResult:
|
|
scopes, groups = _scopes_and_groups()
|
|
prompts = [
|
|
Prompt(name=name, description=desc or None)
|
|
for name, _group, desc in list_visible_skills(scopes, groups)
|
|
]
|
|
return ListPromptsResult(prompts=prompts)
|
|
|
|
|
|
async def _on_get_prompt(
|
|
ctx: ServerRequestContext,
|
|
params: GetPromptRequestParams,
|
|
) -> GetPromptResult:
|
|
scopes, groups = _scopes_and_groups()
|
|
name = str(params.name or "").strip()
|
|
text = prompt_skill_md_text(name, scopes, groups)
|
|
if text is None:
|
|
raise MCPError(INVALID_PARAMS, "prompt not found or not authorized")
|
|
skills = {n: d for n, _g, d in list_visible_skills(scopes, groups)}
|
|
return GetPromptResult(
|
|
description=skills.get(name) or None,
|
|
messages=[
|
|
PromptMessage(
|
|
role="user",
|
|
content=TextContent(type="text", text=text),
|
|
)
|
|
],
|
|
)
|
|
|
|
|
|
def build_mcp_server() -> Server[Any]:
|
|
server: Server[Any] = Server(
|
|
"netx",
|
|
version=netx_version(),
|
|
instructions=_BASE_INSTRUCTIONS_STATIC,
|
|
on_list_tools=_on_list_tools,
|
|
on_call_tool=_on_call_tool,
|
|
on_list_resources=_on_list_resources,
|
|
on_read_resource=_on_read_resource,
|
|
on_list_prompts=_on_list_prompts,
|
|
on_get_prompt=_on_get_prompt,
|
|
)
|
|
|
|
# Server.instructions is fixed at construct time; override init options so
|
|
# each initialize sees a catalog filtered by the current token / ?groups=.
|
|
_orig_create = server.create_initialization_options
|
|
|
|
def _create_initialization_options(*args: Any, **kwargs: Any) -> Any:
|
|
opts = _orig_create(*args, **kwargs)
|
|
scopes, groups = _scopes_and_groups()
|
|
return opts.model_copy(update={"instructions": build_instructions(scopes, groups)})
|
|
|
|
server.create_initialization_options = _create_initialization_options # type: ignore[method-assign]
|
|
return server
|
|
|
|
|
|
_BASE_INSTRUCTIONS_STATIC = (
|
|
"netx remote MCP: ops / topology / bizMonitor tools. "
|
|
"Filter with ?groups=ops,topology,bizMonitor. "
|
|
"Authorize with Bearer MCP token or user JWT."
|
|
)
|
|
|
|
|
|
# Singleton used by mount; streamable_http_app() must be called once so session_manager exists.
|
|
mcp_server = build_mcp_server()
|