From 89b856a00c8f86b3e329290e374c39600d7c8945 Mon Sep 17 00:00:00 2001 From: Ailcope <54411234+Ailcope@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:41:15 +0200 Subject: [PATCH 1/7] chore(deps): move to the mcp 2.x SDK The 1.x line is in maintenance mode (security fixes only) and the `<2` pin from #32 was always meant to be temporary. Dependabot's range bump (#39) could not do it alone: 2.0 is a breaking release for this code base, so the port lands in the dedicated commits that follow. - `mcp[cli]>=1.0,<2` -> `mcp>=2.3,<3`. The `[cli]` extra is dropped: it only pulled typer for the `mcp` command-line tool the server never uses. `httpx` stays for bmc/redfish.py; the SDK's `httpx2` arrives as a transitive dependency and the two packages coexist. --- pyproject.toml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2421871..0cd45a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,11 +8,8 @@ version = "2.0.0" description = "Remote MCP server for Proxmox VE and BMC (iLO/IPMI) infrastructure management" requires-python = ">=3.11" dependencies = [ - # 2.0 renamed mcp.server.fastmcp to mcp.server.mcpserver and FastMCP to - # MCPServer, which every tool module imports. Hold at 1.x until that - # migration lands; the range was open-ended, so CI started installing - # 2.0 the day it shipped and stopped collecting four test modules. - "mcp[cli]>=1.0,<2", + # 2.x: mcp.server.mcpserver / MCPServer (see docs/updates.md for the migration). + "mcp>=2.3,<3", "proxmoxer", "requests", "python-hpilo", From be00c80d5dc33880b98006db5edd7517a7b0d1df Mon Sep 17 00:00:00 2001 From: Ailcope <54411234+Ailcope@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:41:15 +0200 Subject: [PATCH 2/7] feat(server): build on MCPServer, pass transport security to the app `mcp.server.fastmcp.FastMCP` is `mcp.server.mcpserver.MCPServer` in 2.x, and three things around it changed shape. The transport parameters left the constructor: without an explicit `transport_security` the 2.x app factory auto-enables a loopback-only allowlist, so every public Host (claude.ai, ChatGPT, Le Chat) would have been answered 421. Only `ToolError` keeps its message for the model now; any other exception is reported as a generic "Error executing tool X" with the text kept server-side, which would have hidden "node not found" and friends. And the server version defaults to "" instead of the SDK's. - The 10 modules import `MCPServer`; `Icon(mime_type=...)`. - `server.py` exposes a module-level `transport_security` (DNS-rebinding protection, `allowed_hosts`, `allowed_origins`) and `_run_http` passes it to `mcp.streamable_http_app(...)`. The parent Starlette lifespan uses the public `mcp.session_manager.run()` instead of reaching into the child app's router. - `MCPServer("beaconmcp", ..., version=)`. - `_metric_tool` keeps its async wrapper (the SDK only offloads sync functions, so there is no double offload) and now re-raises `ToolError`/`MCPError` as is, converts anything else to `ToolError(str(e))`, and counts a cancelled call as `cancelled` instead of `ok`. Comment rewritten: the "FastMCP runs sync tools inline" justification no longer holds. --- src/beaconmcp/__main__.py | 18 +++++---- src/beaconmcp/bmc/tools.py | 4 +- src/beaconmcp/maintenance/tools.py | 4 +- src/beaconmcp/proxmox/aggregators.py | 4 +- src/beaconmcp/proxmox/monitoring.py | 4 +- src/beaconmcp/proxmox/panel.py | 14 +++---- src/beaconmcp/proxmox/system.py | 4 +- src/beaconmcp/proxmox/vms.py | 4 +- src/beaconmcp/security/tools.py | 4 +- src/beaconmcp/server.py | 56 ++++++++++++++++++++-------- src/beaconmcp/ssh/tools.py | 4 +- 11 files changed, 73 insertions(+), 47 deletions(-) diff --git a/src/beaconmcp/__main__.py b/src/beaconmcp/__main__.py index 06f5e1c..0e050b9 100644 --- a/src/beaconmcp/__main__.py +++ b/src/beaconmcp/__main__.py @@ -1089,7 +1089,7 @@ def _run_http(mcp, host: str, port: int): current_bearer_token, ) from .ratelimit import RateLimiter, client_ip, forwarded_host - from .server import config + from .server import config, transport_security from .metrics import REGISTRY, auth_events, http_requests @@ -2148,16 +2148,18 @@ async def __call__(self, scope, receive, send): scope["raw_path"] = new_path.encode("ascii") await self._inner(scope, receive, send) - mcp_app = _McpSlugRewriteApp(mcp.streamable_http_app()) + mcp_app = _McpSlugRewriteApp( + mcp.streamable_http_app(transport_security=transport_security) + ) - # The MCP streamable-HTTP app starts its session manager task group in its - # own lifespan. When we Mount it under a parent Starlette, only the parent - # app's lifespan runs — so we forward the child's lifespan explicitly, - # otherwise requests fail with "Task group is not initialized". - inner_mcp = mcp_app._inner + # The MCP streamable-HTTP app starts its session manager in its own + # lifespan. Mount does not run a child's lifespan, so the parent + # Starlette's lifespan must start the StreamableHTTP session manager + # itself; mcp 2.x prescribes ``session_manager.run()`` for that, otherwise + # requests fail with "Task group is not initialized". @asynccontextmanager async def lifespan(_app): - async with inner_mcp.router.lifespan_context(_app): + async with mcp.session_manager.run(): yield dcr_routes: list = [] diff --git a/src/beaconmcp/bmc/tools.py b/src/beaconmcp/bmc/tools.py index 6288656..efac08b 100644 --- a/src/beaconmcp/bmc/tools.py +++ b/src/beaconmcp/bmc/tools.py @@ -10,14 +10,14 @@ from typing import Any -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from ..utils import filter_fields from .base import BMCClient, BMCNotConfiguredError, BMCTunnelError def register_bmc_tools( - mcp: FastMCP, registry: dict[str, BMCClient] + mcp: MCPServer, registry: dict[str, BMCClient] ) -> None: """Register BMC management tools on the MCP server. diff --git a/src/beaconmcp/maintenance/tools.py b/src/beaconmcp/maintenance/tools.py index ec6364c..7a87af4 100644 --- a/src/beaconmcp/maintenance/tools.py +++ b/src/beaconmcp/maintenance/tools.py @@ -4,7 +4,7 @@ from pathlib import Path -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from .. import audit, updates from ..auth import current_client_id @@ -12,7 +12,7 @@ def register_maintenance_tools( - mcp: FastMCP, + mcp: MCPServer, settings: UpdatesConfig | None = None, *, config_path: Path | None = None, diff --git a/src/beaconmcp/proxmox/aggregators.py b/src/beaconmcp/proxmox/aggregators.py index 4a2c1e9..6817e68 100644 --- a/src/beaconmcp/proxmox/aggregators.py +++ b/src/beaconmcp/proxmox/aggregators.py @@ -25,7 +25,7 @@ from concurrent.futures import ThreadPoolExecutor from typing import Any -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from ..config import Config from ..utils import filter_fields @@ -232,7 +232,7 @@ def _recent_errors(client: ProxmoxClient, node: str, limit: int = 20) -> list[di # --------------------------------------------------------------------------- def register_aggregator_tools( - mcp: FastMCP, + mcp: MCPServer, proxmox_client: ProxmoxClient, config: Config, bmc_registry: dict | None = None, diff --git a/src/beaconmcp/proxmox/monitoring.py b/src/beaconmcp/proxmox/monitoring.py index fe0bc82..f49c9fe 100644 --- a/src/beaconmcp/proxmox/monitoring.py +++ b/src/beaconmcp/proxmox/monitoring.py @@ -2,13 +2,13 @@ from typing import Any -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from ..utils import filter_fields from .client import ProxmoxClient -def register_monitoring_tools(mcp: FastMCP, client: ProxmoxClient) -> None: +def register_monitoring_tools(mcp: MCPServer, client: ProxmoxClient) -> None: """Register all Proxmox monitoring and diagnostic tools.""" @mcp.tool() diff --git a/src/beaconmcp/proxmox/panel.py b/src/beaconmcp/proxmox/panel.py index 0dd1670..459fbad 100644 --- a/src/beaconmcp/proxmox/panel.py +++ b/src/beaconmcp/proxmox/panel.py @@ -5,10 +5,10 @@ resource served as `text/html;profile=mcp-app`, which the host renders in a sandboxed iframe and talks to over JSON-RPC on `postMessage`. -The wire format is all this module needs, so it runs on mcp 1.x. The `Apps` -extension class that wraps it lives in mcp 2.0 and requires `MCPServer`; the -two knobs it sets -- `meta=` on the tool and `mime_type=` on the resource -- -are already on `FastMCP`. +The wire format is all this module needs, so it does not go through the SDK's +`Apps` extension class: the two knobs that class would set -- `meta=` on the +tool and `mime_type=` on the resource -- are plain `MCPServer` arguments, and +the `ui/initialize` handshake is handled by the panels' own bridge. Hosts that did not negotiate Apps ignore `_meta.ui` and just show the tool's return value, which is why every panel tool returns its full snapshot as data @@ -25,7 +25,7 @@ from pathlib import Path from typing import Any -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from .aggregators import ( _collect_node_summaries, @@ -95,7 +95,7 @@ def _vm_snapshot(client: ProxmoxClient, node: str, vmid: int) -> dict[str, Any]: return {"error": f"VM/CT {vmid} not found on node '{node}'. Check the VMID and node name."} -def register_panel_tools(mcp: FastMCP, client: ProxmoxClient) -> None: +def register_panel_tools(mcp: MCPServer, client: ProxmoxClient) -> None: @mcp.tool( meta={"ui": {"resourceUri": VM_PANEL_URI, "visibility": ["model", "app"]}}, ) @@ -215,7 +215,7 @@ def _syslog_level(line: str) -> str: def _register_app_resource( - mcp: FastMCP, uri: str, name: str, title: str, filename: str, + mcp: MCPServer, uri: str, name: str, title: str, filename: str, ) -> None: @mcp.resource(uri, name=name, title=title, mime_type=APP_MIME_TYPE) def _app() -> str: diff --git a/src/beaconmcp/proxmox/system.py b/src/beaconmcp/proxmox/system.py index b28d4ca..089fe17 100644 --- a/src/beaconmcp/proxmox/system.py +++ b/src/beaconmcp/proxmox/system.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import Any -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from .client import ProxmoxClient @@ -134,7 +134,7 @@ def _qemu_file_size(client: ProxmoxClient, node: str, vmid: int, path: str) -> i return None -def register_system_tools(mcp: FastMCP, client: ProxmoxClient, ssh_client: Any = None) -> None: +def register_system_tools(mcp: MCPServer, client: ProxmoxClient, ssh_client: Any = None) -> None: """Register Proxmox system administration and command execution tools.""" diff --git a/src/beaconmcp/proxmox/vms.py b/src/beaconmcp/proxmox/vms.py index 6d3c7bc..e2b8ef4 100644 --- a/src/beaconmcp/proxmox/vms.py +++ b/src/beaconmcp/proxmox/vms.py @@ -3,7 +3,7 @@ import re from typing import Any -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from ..utils import filter_fields from .client import ProxmoxClient @@ -64,7 +64,7 @@ def _detect_backup_type(client: ProxmoxClient, node: str, archive: str) -> str | return None -def register_vm_tools(mcp: FastMCP, client: ProxmoxClient) -> None: +def register_vm_tools(mcp: MCPServer, client: ProxmoxClient) -> None: """Register all Proxmox VM/CT lifecycle management tools.""" @mcp.tool() diff --git a/src/beaconmcp/security/tools.py b/src/beaconmcp/security/tools.py index 1b610d9..d497fe7 100644 --- a/src/beaconmcp/security/tools.py +++ b/src/beaconmcp/security/tools.py @@ -2,12 +2,12 @@ from __future__ import annotations -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from ..auth import revoke_current_token -def register_security_tools(mcp: FastMCP) -> None: +def register_security_tools(mcp: MCPServer) -> None: @mcp.tool() def security_end_session() -> dict: """Invalidate the current bearer token. diff --git a/src/beaconmcp/server.py b/src/beaconmcp/server.py index 967a0ad..49dfb40 100644 --- a/src/beaconmcp/server.py +++ b/src/beaconmcp/server.py @@ -1,12 +1,15 @@ import base64 import inspect import os +from importlib.metadata import PackageNotFoundError, version as _pkg_version from pathlib import Path import anyio -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.exceptions import ToolError from mcp.server.transport_security import TransportSecuritySettings +from mcp.shared.exceptions import MCPError from mcp.types import Icon from .bmc import build_registry as build_bmc_registry @@ -70,7 +73,7 @@ def _load_icons() -> list[Icon]: return [ Icon( src=f"data:image/webp;base64,{data}", - mimeType="image/webp", + mime_type="image/webp", sizes=["512x512"], ) ] @@ -119,29 +122,44 @@ def _build_instructions() -> str: return body + entry_line -mcp = FastMCP( +try: + _version = _pkg_version("beaconmcp") +except PackageNotFoundError: + _version = "" + +# Must be passed to ``mcp.streamable_http_app(transport_security=...)``: the +# 2.x MCPServer constructor has no such parameter, and with +# ``transport_security=None`` the factory auto-enables a loopback-only +# allowlist (``127.0.0.1:*``, ``localhost:*``), so every public Host header +# would get a 421. +transport_security = TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=_allowed_hosts, + allowed_origins=_allowed_origins, +) + +mcp = MCPServer( "beaconmcp", instructions=_build_instructions(), website_url="https://github.com/Showdown76py/BeaconMCP", icons=_load_icons(), - transport_security=TransportSecuritySettings( - enable_dns_rebinding_protection=True, - allowed_hosts=_allowed_hosts, - allowed_origins=_allowed_origins, - ), + version=_version, ) # Wrap mcp.tool to inject metrics tracking. # -# FastMCP (mcp 1.27) runs sync tool functions INLINE in the asyncio event -# loop -- it never offloads them to a thread. Most Proxmox tools are sync +# The wrapper is kept on purpose: it measures latency, emits the audit event, +# and keeps blocking work off the event loop. Most Proxmox tools are sync # ``def`` wrappers around blocking proxmoxer calls (5s timeout x 2 retries), -# so a single unreachable node would otherwise freeze the entire server. -# We therefore make EVERY registered tool async from FastMCP's view and push -# sync bodies onto a worker thread, keeping blocking work off the event loop. -# ``@wraps(func)`` preserves the original signature so FastMCP still derives -# the tool input schema correctly. +# so we run sync bodies on a worker thread via ``anyio.to_thread``. The SDK +# only ever sees async tools (it offloads sync functions itself, so an +# ``async def`` wrapper avoids a double offload). ``@wraps(func)`` preserves +# the original signature so the tool input schema is still derived correctly. +# +# In mcp 2.x only ``ToolError`` keeps its message for the model; any other +# exception becomes a generic "Error executing tool X" and the text stays +# server-side. We therefore convert unexpected exceptions to ``ToolError``. _orig_tool = mcp.tool def _metric_tool(*args, **kwargs): def decorator(func): @@ -158,9 +176,15 @@ async def wrapper(*f_args, **f_kwargs): return await anyio.to_thread.run_sync( partial(func, *f_args, **f_kwargs) ) - except Exception: + except anyio.get_cancelled_exc_class(): + status = "cancelled" + raise + except (ToolError, MCPError): status = "error" raise + except Exception as e: + status = "error" + raise ToolError(str(e)) from e finally: latency = (time.monotonic() - start) * 1000 tool_calls.inc(tool=tool_name, status=status) diff --git a/src/beaconmcp/ssh/tools.py b/src/beaconmcp/ssh/tools.py index 7411c21..2ef1c23 100644 --- a/src/beaconmcp/ssh/tools.py +++ b/src/beaconmcp/ssh/tools.py @@ -4,7 +4,7 @@ import time from typing import Any -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from .client import SSHClient, SSHHostResolutionError, SSHNotConfiguredError @@ -32,7 +32,7 @@ def _session_to_result(exec_id: str, session) -> dict[str, Any]: } -def register_ssh_tools(mcp: FastMCP, ssh_client: SSHClient) -> None: +def register_ssh_tools(mcp: MCPServer, ssh_client: SSHClient) -> None: """Register SSH command execution tools.""" @mcp.tool() From 7a8d321a715c82056fa9cf94bb066be446c3089c Mon Sep 17 00:00:00 2001 From: Ailcope <54411234+Ailcope@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:41:15 +0200 Subject: [PATCH 3/7] fix(dashboard): open the MCP session with the 2.x client API `streamablehttp_client` is gone in 2.x: headers and timeouts now live on an `httpx2.AsyncClient` handed to `streamable_http_client`, which yields a 2-tuple. `AppsClientSession` had to go too: it tagged the ui extension onto `InitializeRequest` by testing `request.root`, and 2.x request types are no longer RootModels, so the check would have been silently False and the MCP Apps panels would have stopped rendering without any error. Result and tool fields are snake_case in 2.x; the `getattr(..., "isError")` reads would have defaulted to False/None and counted errors as ok. - `open_session` keeps its signature, builds the httpx2 client with the bearer and `Timeout(connect=30, read=300)`, and declares the ui extension through `ClientSession(extensions=CLIENT_EXTENSIONS)`. - `chat.py` reuses `open_session` instead of its own copy of the handshake, and reads `is_error`, `input_schema`, `mime_type`, `structured_content`. Wire keys sent to the browser and to Gemini (`isError`, `mimeType`, `structuredContent`) are unchanged. --- src/beaconmcp/dashboard/chat.py | 82 +++++++++++++-------------- src/beaconmcp/dashboard/mcp_bridge.py | 73 +++++++++--------------- 2 files changed, 69 insertions(+), 86 deletions(-) diff --git a/src/beaconmcp/dashboard/chat.py b/src/beaconmcp/dashboard/chat.py index 7d06d2d..29c6f8b 100644 --- a/src/beaconmcp/dashboard/chat.py +++ b/src/beaconmcp/dashboard/chat.py @@ -20,6 +20,7 @@ import sys import time import traceback +from contextlib import AsyncExitStack from dataclasses import dataclass, field from typing import Any, AsyncIterator, Awaitable, Callable, Iterable, Protocol, runtime_checkable @@ -223,7 +224,7 @@ class UsageAccumulated: # the modal on ``dry_run=True``. # # This list must stay exhaustive, and honouring dry_run cannot be assumed -# from the argument alone: FastMCP validates tool arguments with a plain +# from the argument alone: the MCP SDK validates tool arguments with a plain # pydantic model, so pydantic's default ``extra='ignore'`` silently drops # any argument the tool does not declare. A trusting # ``args.get("dry_run") is True`` check would therefore let @@ -667,13 +668,9 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: # ``session.call_tool()``, and feed the results back in a new # ``generate_content_stream`` call until no function_call remains. try: - from mcp.client.streamable_http import ( # type: ignore - streamablehttp_client, - ) - from .mcp_bridge import ( - AppsClientSession, call_result_to_wire, + open_session, ui_resource_uri, ui_resource_uris_by_tool, ) @@ -686,36 +683,39 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: turn.model, turn.effort, turn.mcp_url, ) - # ``streamablehttp_client`` (no underscore) is the variant that - # actually threads the ``headers`` dict through every request. - # Previously we used ``streamable_http_client(http_client=...)`` - # and relied on httpx default-headers propagation, but the MCP - # client builds fresh requests that don't inherit the defaults, - # so the Authorization header was being dropped -- producing a - # hard 401 on /mcp even when calling loopback. - async with streamablehttp_client( - turn.mcp_url, - headers={"Authorization": f"Bearer {turn.bearer}"}, - timeout=30, - sse_read_timeout=300, - ) as (read_stream, write_stream, _get_session_id): - # Apps-aware session: it declares io.modelcontextprotocol/ui at - # initialize, which is what lets the dashboard render the ui:// - # panels its own tools point at instead of showing their JSON. - async with AppsClientSession(read_stream, write_stream) as session: - try: - init_result = await session.initialize() - except Exception as e: # noqa: BLE001 - yield ErrorEvent( - code="mcp_init_failed", - message=( - f"Cannot connect to MCP ({turn.mcp_url}): {e}. " - "Make sure BEACONMCP_DASHBOARD_PUBLIC_URL is " - "not set when running in local mode." - ), - ) - return + # ``open_session`` carries the bearer and timeouts on its httpx2 + # client and declares io.modelcontextprotocol/ui at initialize, + # which is what lets the dashboard render the ui:// panels its own + # tools point at instead of showing their JSON. It initializes + # before yielding, so handshake failures surface when entering it; + # the stack keeps them reportable as an ErrorEvent. + stack = AsyncExitStack() + try: + session = await stack.enter_async_context( + open_session( + turn.mcp_url, + turn.bearer, + timeout=30, + sse_read_timeout=300, + ) + ) + # Already initialized: returns the cached handshake result. + init_result = await session.initialize() + except Exception as e: # noqa: BLE001 + # Whatever was entered before the failure is released here, we + # don't reach the ``async with stack`` below. + await stack.aclose() + yield ErrorEvent( + code="mcp_init_failed", + message=( + f"Cannot connect to MCP ({turn.mcp_url}): {e}. " + "Make sure BEACONMCP_DASHBOARD_PUBLIC_URL is " + "not set when running in local mode." + ), + ) + return + async with stack: # The MCP server ships a dynamic ``instructions`` string # describing which capabilities are currently active # (Proxmox N nodes / SSH N hosts / BMC N devices). Pass @@ -1015,7 +1015,7 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: payload = _mcp_call_result_to_response(result) status = ( "error" - if getattr(result, "isError", False) + if result.is_error else "ok" ) # A result-level _meta.ui wins over the one @@ -1141,13 +1141,13 @@ def _iter_parts(chunk: Any): def _mcp_tool_to_declaration(tool: Any, types_mod: Any) -> Any: """Convert an MCP ``Tool`` into a Gemini ``FunctionDeclaration``. - MCP exposes ``inputSchema`` as a JSON Schema dict which the SDK's + MCP exposes ``input_schema`` as a JSON Schema dict which the SDK's ``parameters_json_schema`` field accepts directly -- no manual translation to ``types.Schema`` needed. """ name = getattr(tool, "name", "") description = getattr(tool, "description", "") or "" - schema = getattr(tool, "inputSchema", None) + schema = tool.input_schema if not isinstance(schema, dict) or not schema: # Gemini requires an object schema. Default to an empty object so # tools without declared parameters still validate. @@ -1174,16 +1174,16 @@ def _mcp_call_result_to_response(result: Any) -> dict: content_items.append({"type": "text", "text": text}) continue data = getattr(c, "data", None) - mime = getattr(c, "mimeType", None) + mime = getattr(c, "mime_type", None) if data is not None: content_items.append({ "type": "binary", "mimeType": mime or "application/octet-stream", }) out["content"] = content_items - if getattr(result, "isError", False): + if result.is_error: out["error"] = True - structured = getattr(result, "structuredContent", None) + structured = result.structured_content if structured is not None: out["structured"] = structured return out diff --git a/src/beaconmcp/dashboard/mcp_bridge.py b/src/beaconmcp/dashboard/mcp_bridge.py index 648d8f3..0ed9a77 100644 --- a/src/beaconmcp/dashboard/mcp_bridge.py +++ b/src/beaconmcp/dashboard/mcp_bridge.py @@ -10,13 +10,10 @@ that declares the Apps extension, a way to read a ``ui://`` document, and the ``_meta.ui.resourceUri`` lookup that ties a tool to its panel. -**On the mcp 1.x pin.** A client announces Apps support through -``ClientCapabilities.extensions``, a field that only exists in mcp 2.0 -- -which is why this looked blocked behind the ``<2`` pin. It is not: -``ClientCapabilities`` is declared ``extra="allow"``, so the field rides -onto the wire under exactly the name 2.0 will emit, and the server reads -the same JSON either way. The pin costs us the typed attribute, not the -capability. +**Apps capability.** A client announces Apps support through +``ClientCapabilities.extensions``. In mcp 2.x ``ClientSession`` takes an +``extensions`` mapping and puts it in the handshake itself, so no subclass +or request rewriting is needed. """ from __future__ import annotations @@ -25,9 +22,9 @@ from contextlib import asynccontextmanager from typing import Any, AsyncIterator -from mcp import types +import httpx2 from mcp.client.session import ClientSession -from mcp.client.streamable_http import streamablehttp_client +from mcp.client.streamable_http import streamable_http_client from pydantic import AnyUrl # Extension identifier and content type from the MCP Apps spec (SEP-1865). @@ -41,27 +38,7 @@ _UI_CAPABILITY: dict[str, Any] = {"mimeTypes": [APP_MIME_TYPE]} -class AppsClientSession(ClientSession): - """A ``ClientSession`` that declares the MCP Apps extension. - - ``ClientSession.initialize()`` builds its own ``ClientCapabilities`` and - takes no hook for extra fields, so rather than reimplement it -- version - negotiation, the ``notifications/initialized`` follow-up, the capability - bookkeeping -- we tag the outgoing ``InitializeRequest`` on its way past. - Everything else stays the SDK's business. - """ - - async def send_request(self, request, *args, **kwargs): # type: ignore[override] - root = getattr(request, "root", None) - if isinstance(root, types.InitializeRequest): - # extra="allow" on ClientCapabilities: the attribute lands in - # __pydantic_extra__ and serialises as a real field. - setattr( - root.params.capabilities, - "extensions", - {UI_EXTENSION_ID: dict(_UI_CAPABILITY)}, - ) - return await super().send_request(request, *args, **kwargs) +CLIENT_EXTENSIONS: dict[str, dict[str, Any]] = {UI_EXTENSION_ID: _UI_CAPABILITY} @asynccontextmanager @@ -74,19 +51,25 @@ async def open_session( ) -> AsyncIterator[ClientSession]: """Open an initialized Apps-aware session against ``mcp_url``. - ``streamablehttp_client`` (no underscore) is the variant that threads - ``headers`` through every request; the other one drops the bearer and - the handshake 401s on loopback. Same reason as in ``chat.py``. + In mcp 2.x the bearer header and the timeouts live on the + ``httpx2.AsyncClient`` handed to ``streamable_http_client``; ``timeout`` + is the connect/write/pool budget and ``sse_read_timeout`` the read one. """ - async with streamablehttp_client( - mcp_url, + async with httpx2.AsyncClient( headers={"Authorization": f"Bearer {bearer}"}, - timeout=timeout, - sse_read_timeout=sse_read_timeout, - ) as (read_stream, write_stream, _session_id): - async with AppsClientSession(read_stream, write_stream) as session: - await session.initialize() - yield session + timeout=httpx2.Timeout(float(timeout), read=float(sse_read_timeout)), + ) as http: + async with streamable_http_client(mcp_url, http_client=http) as ( + read_stream, + write_stream, + ): + async with ClientSession( + read_stream, + write_stream, + extensions={k: dict(v) for k, v in CLIENT_EXTENSIONS.items()}, + ) as session: + await session.initialize() + yield session def ui_resource_uri(meta: Any) -> str | None: @@ -132,16 +115,16 @@ def call_result_to_wire(result: Any) -> dict[str, Any]: if text is not None: content.append({"type": "text", "text": text}) continue - mime = getattr(item, "mimeType", None) + mime = getattr(item, "mime_type", None) content.append({ "type": getattr(item, "type", "resource") or "resource", "mimeType": mime or "application/octet-stream", }) wire: dict[str, Any] = { "content": content, - "isError": bool(getattr(result, "isError", False)), + "isError": bool(result.is_error), } - structured = getattr(result, "structuredContent", None) + structured = result.structured_content if structured is not None: wire["structuredContent"] = structured return wire @@ -169,7 +152,7 @@ async def read_ui_resource(session: ClientSession, uri: str) -> str: text = getattr(item, "text", None) if text is None: continue - mime = (getattr(item, "mimeType", None) or "").replace(" ", "") + mime = (getattr(item, "mime_type", None) or "").replace(" ", "") if mime != APP_MIME_TYPE: raise UiResourceError(f"unexpected mime type {mime or '(none)'}") return text From 7d36c4feb0166f62b3ca419a95581ccb71ff71ff Mon Sep 17 00:00:00 2001 From: Ailcope <54411234+Ailcope@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:41:15 +0200 Subject: [PATCH 4/7] test: port the SDK-facing tests and drive the real /mcp stack - `test_mcp_apps_panel.py`: `MCPServer`, `mime_type`, `call_tool` returns a `CallToolResult`; every panel tool's `input_schema` must be `type: object` (2.x rejects `{}` at serialization). - `test_dashboard_mcp_apps.py`: the two `AppsClientSession` tests are replaced by one that captures the real `initialize` request and checks the ui extension is advertised, and one that checks `open_session` wires the bearer, the timeouts and the extensions. - New `test_mcp_http_stack.py`: captures the app `_run_http` hands to uvicorn and drives it in-process through the parent lifespan: 401 with `resource_metadata` without a bearer, initialize 200 with `Mcp-Session-Id` (negotiates 2025-06-18 when offered), tools/list with object schemas, tools/call, the `/mcp/c/` rewrite, and 421 on a foreign Host. First test to cover auth, slug, lifespan and the DNS-rebinding guard together. - Fakes in `test_dashboard_unit.py` use the snake_case fields. Tests: 517 passed under mcp 2.3.0, ruff clean. --- tests/test_dashboard_mcp_apps.py | 104 +++++++++-------- tests/test_dashboard_unit.py | 12 +- tests/test_mcp_apps_panel.py | 16 +-- tests/test_mcp_http_stack.py | 187 +++++++++++++++++++++++++++++++ tests/test_security_hardening.py | 2 +- tests/test_updates.py | 2 +- 6 files changed, 263 insertions(+), 60 deletions(-) create mode 100644 tests/test_mcp_http_stack.py diff --git a/tests/test_dashboard_mcp_apps.py b/tests/test_dashboard_mcp_apps.py index 86722d9..023860a 100644 --- a/tests/test_dashboard_mcp_apps.py +++ b/tests/test_dashboard_mcp_apps.py @@ -16,6 +16,7 @@ from contextlib import asynccontextmanager from pathlib import Path +import anyio import pytest from starlette.applications import Starlette from starlette.testclient import TestClient @@ -52,13 +53,11 @@ @pytest.mark.anyio async def test_initialize_declares_the_apps_extension(monkeypatch): - """The extension rides on ``ClientCapabilities`` even on mcp 1.x. + """``ClientSession(extensions=...)`` puts the ui capability in the handshake. - ``extensions`` only becomes a typed field in mcp 2.0, which is what made - this look blocked behind the ``<2`` pin. It is not: the model allows - extras, so the field serialises under the name the spec gives it. If a - future SDK bump makes ``ClientCapabilities`` strict, this fails here - rather than silently dropping the capability at runtime. + In mcp 2.x the extension is a typed constructor argument. If a later SDK + bump stops sending it, the server stops advertising ui:// panels to us + and nothing fails visibly, so pin the outgoing InitializeRequest here. """ from mcp import types from mcp.client.session import ClientSession @@ -67,52 +66,67 @@ async def test_initialize_declares_the_apps_extension(monkeypatch): async def fake_send_request(self, request, result_type, **kwargs): captured["request"] = request - return None + raise RuntimeError("stop after the handshake request") monkeypatch.setattr(ClientSession, "send_request", fake_send_request) - session = object.__new__(mcp_bridge.AppsClientSession) - request = types.ClientRequest( - types.InitializeRequest( - params=types.InitializeRequestParams( - protocolVersion=types.LATEST_PROTOCOL_VERSION, - capabilities=types.ClientCapabilities(), - clientInfo=types.Implementation(name="t", version="1"), - ), - ) + read_send, read_recv = anyio.create_memory_object_stream(1) + write_send, write_recv = anyio.create_memory_object_stream(1) + session = ClientSession( + read_recv, write_send, extensions={k: dict(v) for k, v in mcp_bridge.CLIENT_EXTENSIONS.items()} ) - await session.send_request(request, types.InitializeResult) - - wire = captured["request"].model_dump(by_alias=True, exclude_none=True) - extensions = wire["params"]["capabilities"]["extensions"] - assert extensions == { - "io.modelcontextprotocol/ui": { - "mimeTypes": ["text/html;profile=mcp-app"], - } + with pytest.raises(RuntimeError, match="stop after"): + await session.initialize() + + request = captured["request"] + assert isinstance(request, types.InitializeRequest) + assert request.params.capabilities.extensions == { + "io.modelcontextprotocol/ui": {"mimeTypes": ["text/html;profile=mcp-app"]} } @pytest.mark.anyio -async def test_non_initialize_requests_pass_through_untouched(monkeypatch): - """Only the handshake is rewritten; every other request goes as-is.""" - from mcp import types - from mcp.client.session import ClientSession +async def test_open_session_wires_bearer_timeouts_and_extensions(monkeypatch): + import httpx2 - captured: dict = {} + seen: dict = {} - async def fake_send_request(self, request, result_type, **kwargs): - captured["request"] = request - return None - - monkeypatch.setattr(ClientSession, "send_request", fake_send_request) - - session = object.__new__(mcp_bridge.AppsClientSession) - original = types.ClientRequest(types.ListToolsRequest(method="tools/list")) - await session.send_request(original, types.ListToolsResult) - - assert captured["request"] is original - assert captured["request"].model_dump(by_alias=True, exclude_none=True) == { - "method": "tools/list", + @asynccontextmanager + async def fake_transport(url, *, http_client=None, **kwargs): + seen["url"] = url + seen["http"] = http_client + yield ("read", "write") + + class FakeSession: + def __init__(self, read, write, **kwargs): + seen["streams"] = (read, write) + seen["kwargs"] = kwargs + seen["initialized"] = False + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def initialize(self): + seen["initialized"] = True + + monkeypatch.setattr(mcp_bridge, "streamable_http_client", fake_transport) + monkeypatch.setattr(mcp_bridge, "ClientSession", FakeSession) + + async with mcp_bridge.open_session("http://x/mcp", "tok") as session: + assert isinstance(session, FakeSession) + assert seen["initialized"] is True + + assert seen["url"] == "http://x/mcp" + assert seen["streams"] == ("read", "write") + http = seen["http"] + assert isinstance(http, httpx2.AsyncClient) + assert http.headers["Authorization"] == "Bearer tok" + assert http.timeout == httpx2.Timeout(30.0, read=300.0) + assert seen["kwargs"]["extensions"] == { + "io.modelcontextprotocol/ui": {"mimeTypes": ["text/html;profile=mcp-app"]} } @@ -142,7 +156,7 @@ def __init__(self, name, meta): class _FakeResourceContent: def __init__(self, text, mime): self.text = text - self.mimeType = mime + self.mime_type = mime class _FakeReadResult: @@ -329,8 +343,8 @@ async def call_tool(self, name, args): class _Result: content = [] - isError = False - structuredContent = {"ok": True} + is_error = False + structured_content = {"ok": True} return _Result() diff --git a/tests/test_dashboard_unit.py b/tests/test_dashboard_unit.py index 13f9908..eac82e6 100644 --- a/tests/test_dashboard_unit.py +++ b/tests/test_dashboard_unit.py @@ -405,7 +405,7 @@ def test_mcp_tool_to_declaration_passes_input_schema(): class FakeMCPTool: name = "proxmox_list_vms" description = "List VMs on a Proxmox node" - inputSchema = { + input_schema = { "type": "object", "properties": {"node": {"type": "string"}}, "required": ["node"], @@ -425,7 +425,7 @@ def test_mcp_tool_to_declaration_defaults_schema_when_missing(): class FakeMCPTool: name = "ping" description = "" - inputSchema = None + input_schema = None decl = _mcp_tool_to_declaration(FakeMCPTool(), types) # Gemini rejects empty/missing schemas; helper must substitute an @@ -515,8 +515,8 @@ class FakeText: class FakeResult: content = [FakeText()] - isError = False - structuredContent = None + is_error = False + structured_content = None payload = _mcp_call_result_to_response(FakeResult()) assert payload == {"content": [{"type": "text", "text": "hello"}]} @@ -530,8 +530,8 @@ class FakeText: class FakeResult: content = [FakeText()] - isError = True - structuredContent = None + is_error = True + structured_content = None payload = _mcp_call_result_to_response(FakeResult()) assert payload["error"] is True diff --git a/tests/test_mcp_apps_panel.py b/tests/test_mcp_apps_panel.py index ee9b699..c1687af 100644 --- a/tests/test_mcp_apps_panel.py +++ b/tests/test_mcp_apps_panel.py @@ -13,7 +13,7 @@ from typing import Any import pytest -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer sys.path.insert(0, str(Path(__file__).parent.parent / "src")) @@ -67,13 +67,13 @@ def get(self, node: str, path: str, **kwargs: Any) -> Any: return _NOT_FOUND -def _server(client: Any) -> FastMCP: - mcp = FastMCP("test") +def _server(client: Any) -> MCPServer: + mcp = MCPServer("test") register_panel_tools(mcp, client) return mcp -async def _tool(mcp: FastMCP, name: str) -> Any: +async def _tool(mcp: MCPServer, name: str) -> Any: return next(t for t in await mcp.list_tools() if t.name == name) @@ -84,6 +84,8 @@ async def _tool(mcp: FastMCP, name: str) -> Any: async def test_tool_points_at_its_panel(name: str, uri: str) -> None: tool = await _tool(_server(_Client()), name) assert tool.meta == {"ui": {"resourceUri": uri, "visibility": ["model", "app"]}} + # 2.x rejects an empty `{}` schema at serialization. + assert tool.input_schema["type"] == "object" @pytest.mark.parametrize("name,uri", _PANELS) @@ -91,7 +93,7 @@ async def test_advertised_resource_resolves_as_an_app(name: str, uri: str) -> No """A resourceUri that 404s on resources/read renders as a blank frame.""" mcp = _server(_Client()) listed = next(r for r in await mcp.list_resources() if str(r.uri) == uri) - assert listed.mimeType == APP_MIME_TYPE + assert listed.mime_type == APP_MIME_TYPE contents = list(await mcp.read_resource(uri)) assert contents, f"{uri} is advertised but reads back empty" @@ -218,7 +220,7 @@ async def test_logs_panel_labels_syslog_lines() -> None: client = _Client({"syslog": [{"t": "kernel: I/O error"}, {"t": "systemd: Started thing"}]}) tool = await _server(client).call_tool("proxmox_logs_panel", {"node": "pve1"}) - entries = tool[1]["entries"] + entries = tool.structured_content["entries"] assert [e["level"] for e in entries] == ["error", "info"] assert entries[0]["text"] == "kernel: I/O error" @@ -236,7 +238,7 @@ async def test_logs_panel_flags_failed_tasks() -> None: ) # A running task has no status yet and must not be painted as a failure. - assert [e["level"] for e in result[1]["entries"]] == ["info", "error", "info"] + assert [e["level"] for e in result.structured_content["entries"]] == ["info", "error", "info"] async def test_logs_panel_caps_the_line_count() -> None: diff --git a/tests/test_mcp_http_stack.py b/tests/test_mcp_http_stack.py new file mode 100644 index 0000000..3d3e9a9 --- /dev/null +++ b/tests/test_mcp_http_stack.py @@ -0,0 +1,187 @@ +"""End-to-end test of the real /mcp Starlette stack built by ``_run_http``. + +Everything between the socket and the tools is exercised in-process: the +auth middleware, the ``/mcp/c/`` rewrite shim, the streamable HTTP +session manager (started by the parent app's lifespan), and the SDK's +DNS-rebinding guard. No socket is opened: ``uvicorn.run`` is replaced by a +fake that captures the ASGI app, which httpx then drives via ASGITransport. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +import httpx +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +ROOT = Path(__file__).parent.parent +HOST = "mcp.example.com" +ACCEPT = "application/json, text/event-stream" + +# Same throwaway values as the `config` job in .github/workflows/ci.yml. +_CI_ENV = { + "BEACONMCP_SESSION_KEY": "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=", + "GEMINI_API_KEY": "ci-dummy", + "PVE1_TOKEN_SECRET": "ci-dummy", + "PVE2_TOKEN_SECRET": "ci-dummy", + "RACK1_ILO_PASSWORD": "ci-dummy", + "RACK2_IPMI_PASSWORD": "ci-dummy", + "RACK3_IDRAC_PASSWORD": "ci-dummy", + "VPS2_PW": "ci-dummy", +} + + +@pytest.fixture +def stack(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + """Build the app exactly as `_cmd_serve` does and return it with a bearer.""" + for key, value in _CI_ENV.items(): + monkeypatch.setenv(key, value) + monkeypatch.setenv("BEACONMCP_CONFIG", str(ROOT / "beaconmcp.yaml.example")) + monkeypatch.setenv("BEACONMCP_DASHBOARD_DB", str(tmp_path / "dashboard.db")) + monkeypatch.setenv("BEACONMCP_TOKENS_DB", str(tmp_path / "tokens.db")) + monkeypatch.setenv("BEACONMCP_CLIENTS_FILE", str(tmp_path / "clients.json")) + + import uvicorn + + from beaconmcp import __main__ as entry + from beaconmcp import auth + from beaconmcp.server import mcp + + captured: dict[str, Any] = {} + # `_run_http` does `import uvicorn` locally, so patching the attribute on + # the module is enough; the fake keeps the server from binding a port. + monkeypatch.setattr(uvicorn, "run", lambda app, **kw: captured.update(app=app)) + + # Seed a client before the build: `_run_http` loads clients.json itself. + client_id, _secret, _totp = auth.ClientStore(tmp_path / "clients.json").create("t") + + entry._run_http(mcp, "127.0.0.1", 8420) + + # `_run_http` registers its TokenStore with `auth.register_token_store` + # so tools can revoke the caller's bearer; that module-level handle is the + # least invasive way to mint a token that the middleware will accept. + token, _ttl = auth._active_token_store.issue(client_id) + captured["token"] = token + return captured + + +def _client(app: Any) -> httpx.AsyncClient: + return httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url=f"http://{HOST}", + ) + + +def _headers(token: str | None, **extra: str) -> dict[str, str]: + h = {"Accept": ACCEPT, "Content-Type": "application/json", "Host": HOST} + if token: + h["Authorization"] = f"Bearer {token}" + h.update(extra) + return h + + +def _body(resp: httpx.Response) -> dict[str, Any]: + """The SDK may answer plain JSON or a one-event SSE stream.""" + if resp.headers.get("content-type", "").startswith("text/event-stream"): + for line in resp.text.splitlines(): + if line.startswith("data:"): + return json.loads(line[5:].strip()) + raise AssertionError(f"no data line in SSE body: {resp.text!r}") + return resp.json() + + +def _rpc(method: str, id_: int | None = None, params: dict | None = None) -> dict: + msg: dict[str, Any] = {"jsonrpc": "2.0", "method": method} + if id_ is not None: + msg["id"] = id_ + if params is not None: + msg["params"] = params + return msg + + +_INIT = _rpc("initialize", 1, { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "pytest", "version": "0"}, +}) + + +async def _handshake(c: httpx.AsyncClient, token: str, path: str = "/mcp") -> dict[str, str]: + resp = await c.post(path, json=_INIT, headers=_headers(token)) + assert resp.status_code == 200, resp.text + sid = resp.headers["mcp-session-id"] + h = _headers(token, **{"Mcp-Session-Id": sid, "MCP-Protocol-Version": "2025-06-18"}) + done = await c.post(path, json=_rpc("notifications/initialized"), headers=h) + assert done.status_code == 202, done.text + return h + + +async def test_missing_bearer_is_401_with_resource_metadata(stack) -> None: + app = stack["app"] + async with app.router.lifespan_context(app), _client(app) as c: + resp = await c.post("/mcp", json=_INIT, headers=_headers(None)) + assert resp.status_code == 401 + assert "resource_metadata=" in resp.headers["www-authenticate"] + + +async def test_initialize_returns_session_and_protocol_version(stack) -> None: + app = stack["app"] + async with app.router.lifespan_context(app), _client(app) as c: + resp = await c.post("/mcp", json=_INIT, headers=_headers(stack["token"])) + assert resp.status_code == 200, resp.text + assert resp.headers["mcp-session-id"] + # The client proposed 2025-06-18 and mcp 2.3.0 supports it, so the server + # echoes it back instead of downgrading. + assert _body(resp)["result"]["protocolVersion"] == "2025-06-18" + + +async def test_tools_list_schemas_are_objects(stack) -> None: + app = stack["app"] + async with app.router.lifespan_context(app), _client(app) as c: + h = await _handshake(c, stack["token"]) + resp = await c.post("/mcp", json=_rpc("tools/list", 2), headers=h) + assert resp.status_code == 200, resp.text + tools = _body(resp)["result"]["tools"] + assert len(tools) > 40 + bad = [t["name"] for t in tools if t["inputSchema"].get("type") != "object"] + assert not bad, f"inputSchema is not an object schema for: {bad}" + + +async def test_tool_call_answers_from_config(stack) -> None: + """security_end_session needs no network: it only touches the token store.""" + app = stack["app"] + async with app.router.lifespan_context(app), _client(app) as c: + h = await _handshake(c, stack["token"]) + resp = await c.post( + "/mcp", + json=_rpc("tools/call", 3, {"name": "security_end_session", "arguments": {}}), + headers=h, + ) + assert resp.status_code == 200, resp.text + result = _body(resp)["result"] + assert result["isError"] is False + # A bare `dict` return has no output schema, so the payload is the text block. + payload = json.loads(result["content"][0]["text"]) + assert payload["revoked"] is True + + +async def test_slug_path_is_rewritten_to_mcp(stack) -> None: + app = stack["app"] + async with app.router.lifespan_context(app), _client(app) as c: + resp = await c.post("/mcp/c/some-slug", json=_INIT, headers=_headers(stack["token"])) + assert resp.status_code == 200, resp.text + assert resp.headers["mcp-session-id"] + assert "protocolVersion" in _body(resp)["result"] + + +async def test_unknown_host_is_rejected_with_421(stack) -> None: + app = stack["app"] + async with app.router.lifespan_context(app), _client(app) as c: + resp = await c.post( + "/mcp", json=_INIT, headers=_headers(stack["token"], Host="evil.example.net"), + ) + assert resp.status_code == 421 diff --git a/tests/test_security_hardening.py b/tests/test_security_hardening.py index cd40a4b..b883e77 100644 --- a/tests/test_security_hardening.py +++ b/tests/test_security_hardening.py @@ -174,7 +174,7 @@ def test_read_only_calls_do_not_require_confirmation(name: str, args: dict) -> N def test_dry_run_does_not_bypass_gate_on_tools_lacking_it(name: str, args: dict) -> None: """A stray ``dry_run`` must not talk a tool past the modal. - None of these tools declare ``dry_run``, and FastMCP validates + None of these tools declare ``dry_run``, and the MCP SDK validates arguments with a plain pydantic model, so the extra key is dropped server-side and the call runs for real. Trusting the argument would make the gate bypassable by adding one word to an injected diff --git a/tests/test_updates.py b/tests/test_updates.py index 655217a..e509d0e 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -834,7 +834,7 @@ def test_pages_and_api_are_never_cached(tmp_path, monkeypatch): # --------------------------------------------------------------------------- class _RecordingMCP: - """Minimal stand-in for FastMCP that just collects registrations.""" + """Minimal stand-in for MCPServer that just collects registrations.""" def __init__(self): self.tools: dict[str, object] = {} From ece9ec6356e260e6274ba4dfdc21a18ec91c0c31 Mon Sep 17 00:00:00 2001 From: Ailcope <54411234+Ailcope@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:41:15 +0200 Subject: [PATCH 5/7] ci: import the server modules from the wheel and the image `beaconmcp --help` and `validate-config` never import the MCP SDK, so a break like the 2.0 rename could pass the build and docker jobs. Both now run `import beaconmcp.server` (and the dashboard bridge from the wheel) with the dummy secrets of the config job; the image gets the example yaml mounted at the path its `BEACONMCP_CONFIG` points to. --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e605ba..22b1f59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,6 +106,21 @@ jobs: /tmp/smoke/bin/pip install dist/*.whl /tmp/smoke/bin/beaconmcp --help + # --help and validate-config never import the MCP SDK, so exercise the + # real import path here: an SDK API break must fail CI. + - name: Import the server modules from the installed wheel + env: + BEACONMCP_CONFIG: beaconmcp.yaml.example + BEACONMCP_SESSION_KEY: MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA= + GEMINI_API_KEY: ci-dummy + PVE1_TOKEN_SECRET: ci-dummy + PVE2_TOKEN_SECRET: ci-dummy + RACK1_ILO_PASSWORD: ci-dummy + RACK2_IPMI_PASSWORD: ci-dummy + RACK3_IDRAC_PASSWORD: ci-dummy + VPS2_PW: ci-dummy + run: /tmp/smoke/bin/python -c 'import beaconmcp.server, beaconmcp.dashboard.mcp_bridge' + docker: runs-on: ubuntu-latest steps: @@ -125,3 +140,16 @@ jobs: - name: Smoke-test the entrypoint run: docker run --rm beaconmcp:ci --help + + # The image sets BEACONMCP_CONFIG=/config/beaconmcp.yaml and the server + # module loads the config at import, so mount the example there. + - name: Import the server module inside the image + run: | + docker run --rm --entrypoint python \ + -v "$PWD/beaconmcp.yaml.example:/config/beaconmcp.yaml:ro" \ + -e BEACONMCP_SESSION_KEY=MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA= \ + -e GEMINI_API_KEY=ci-dummy -e PVE1_TOKEN_SECRET=ci-dummy \ + -e PVE2_TOKEN_SECRET=ci-dummy -e RACK1_ILO_PASSWORD=ci-dummy \ + -e RACK2_IPMI_PASSWORD=ci-dummy -e RACK3_IDRAC_PASSWORD=ci-dummy \ + -e VPS2_PW=ci-dummy \ + beaconmcp:ci -c 'import beaconmcp.server' From f2f4acf5ba18f0825972e5037963a8bcfedfc4ca Mon Sep 17 00:00:00 2001 From: Ailcope <54411234+Ailcope@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:41:15 +0200 Subject: [PATCH 6/7] docs: mcp 2.x upgrade note, MCPServer wording - `docs/updates.md`: how to move an existing install to this release (pip reinstall + restart, Docker pull, rollback). - `docs/dashboard.md`, `CLAUDE.md`: `MCPServer`, `streamable_http_client`, no more `<2` pin wording. --- CLAUDE.md | 2 +- docs/dashboard.md | 4 ++-- docs/updates.md | 9 +++++++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5e1e477..a5b5861 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ beaconmcp serve # Start HTTP server on :8420 ``` src/beaconmcp/ __main__.py CLI: serve + auth + validate-config - server.py FastMCP server, registers every tool module + server.py MCPServer (`mcp.server.mcpserver`), registers every tool module config.py YAML loader with ${ENV} resolver + legacy env fallback auth.py OAuth 2.1 client credentials (ClientStore + TokenStore) proxmox/ diff --git a/docs/dashboard.md b/docs/dashboard.md index d27239f..4fb738c 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -121,7 +121,7 @@ The allow-list is hard-coded in `src/beaconmcp/dashboard/chat.py` (`_NEEDS_CONFI Tools that carry `_meta.ui.resourceUri` — `proxmox_vm_panel`, `proxmox_logs_panel`, `cluster_overview_interactive` — render as a live interface in the chat instead of a block of JSON. The dashboard implements both halves of the [MCP Apps extension](https://modelcontextprotocol.io/extensions/apps/overview): it announces `io.modelcontextprotocol/ui` when it opens its MCP session, and it plays host to the `ui://` document over `postMessage`. -The extension is declared through `ClientCapabilities.extensions`, a field mcp only types in 2.0. The `<2` pin is not in the way: the model accepts extra fields, so the capability serialises under the name the spec gives it and the server reads the same JSON either way (`dashboard/mcp_bridge.py`). +The extension is declared through `ClientCapabilities.extensions`, a field mcp types from 2.0 on (the project pins `mcp>=2.3,<3`). Even without that typing the model accepts extra fields, so the capability serialises under the name the spec gives it and the server reads the same JSON either way (`dashboard/mcp_bridge.py`). ### How a panel is isolated @@ -191,7 +191,7 @@ Constants live in `src/beaconmcp/dashboard/usage.py` — update them when Google ## Internal MCP architecture -The dashboard keeps its own MCP session (`streamablehttp_client` + `ClientSession`) pointed at the local `/mcp` endpoint (`http://127.0.0.1:8420/mcp`). Tools are converted manually into `FunctionDeclaration` objects and the `function_call` / `function_response` loop is orchestrated server-side (AFC SDK disabled via `AutomaticFunctionCallingConfig(disable=True)`). This works around known `google-genai` bugs with Gemini 2.5 Pro + MCP + streaming + thinking. +The dashboard keeps its own MCP session (`streamable_http_client` + `ClientSession`) pointed at the local `/mcp` endpoint (`http://127.0.0.1:8420/mcp`). Tools are converted manually into `FunctionDeclaration` objects and the `function_call` / `function_response` loop is orchestrated server-side (AFC SDK disabled via `AutomaticFunctionCallingConfig(disable=True)`). This works around known `google-genai` bugs with Gemini 2.5 Pro + MCP + streaming + thinking. > A remote mode (server-driven `McpServer`) exists but is disabled: requests through Cloudflare Tunnel lose the `Authorization` header, producing systematic 500 INTERNAL responses. Setting `BEACONMCP_DASHBOARD_MCP_MODE=remote` logs a startup warning and returns an actionable error on every chat turn. diff --git a/docs/updates.md b/docs/updates.md index 47d213c..4d9e2f1 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -40,6 +40,15 @@ Detection is not a guess about how you *should* have installed it: | pip distribution | `pip install --upgrade 'beaconmcp @ git+https://github.com/Showdown76py/BeaconMCP.git'` | | unknown | Re-run `deploy/install.sh` from a checkout | +## Upgrading to the mcp 2.x SDK + +This release moves the SDK dependency to `mcp>=2.3,<3` (the server now builds on `MCPServer`). + +1. Pip or git install: run `/bin/pip install -e .` (or the pip command from the table above), then `systemctl restart beaconmcp`. +2. Docker: `docker compose pull` then `docker compose up -d`, or rebuild the image if you build it yourself. +3. Run `beaconmcp validate-config`: it works unchanged, your `beaconmcp.yaml` needs no edit. +4. Roll back by checking out the previous tag and reinstalling, then restart. + ## MCP tools Two tools, registered on every deployment shape: From 1dea103ef642239e858195376d331e7940e6f83a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 16:25:15 +0000 Subject: [PATCH 7/7] build: pin the mcp 2.x dependency set, align the upgrade note constraints.txt (added on main by the self-update work) still pinned mcp==1.30.0, so `pip install -e . -c constraints.txt` hit ResolutionImpossible against mcp>=2.3: CI, the Dockerfile, install.sh and the self-update would all have failed to install this tree. Regenerated with the documented recipe; only the SDK set moves (mcp 2.3.0, mcp-types, httpx2, httpcore2, opentelemetry-api, truststore; typer, shellingham, pydantic-settings, httpx-sse and annotated-doc drop out). The upgrade note now uses the constraints file and check-startup. Co-authored-by: Ailcope <54411234+Ailcope@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Lxm6BaCA2kUHH1f6xvh8kS --- constraints.txt | 12 ++++++------ docs/updates.md | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/constraints.txt b/constraints.txt index 42eb637..6d60183 100644 --- a/constraints.txt +++ b/constraints.txt @@ -11,7 +11,6 @@ # python3 -m venv /tmp/c && /tmp/c/bin/pip install -e '.[wizard,dev]' # /tmp/c/bin/pip freeze --exclude-editable | grep -vi '^beaconmcp' >> constraints.txt # (keep this header). -annotated-doc==0.0.5 annotated-types==0.8.0 anyio==4.15.1 asyncssh==2.24.1 @@ -27,8 +26,9 @@ google-auth==2.61.0 google-genai==2.29.0 h11==0.16.0 httpcore==1.0.9 +httpcore2==2.13.1 httpx==0.28.1 -httpx-sse==0.4.3 +httpx2==2.13.1 idna==3.20 iniconfig==2.3.1 Jinja2==3.1.6 @@ -37,9 +37,11 @@ jsonschema-specifications==2025.9.1 linkify-it-py==2.2.0 markdown-it-py==4.2.0 MarkupSafe==3.0.4 -mcp==1.30.0 +mcp==2.3.0 +mcp-types==2.3.0 mdit-py-plugins==0.6.1 mdurl==0.1.2 +opentelemetry-api==1.45.1 packaging==26.3 platformdirs==4.13.0 pluggy==1.6.0 @@ -48,7 +50,6 @@ pyasn1==0.6.4 pyasn1_modules==0.4.2 pycparser==3.11 pydantic==2.14.0 -pydantic-settings==2.15.0 pydantic_core==2.50.0 Pygments==2.21.0 PyJWT==2.15.1 @@ -66,13 +67,12 @@ requests==2.34.2 rich==15.0.0 rpds-py==2026.9.1 ruff==0.17.0 -shellingham==1.5.4 sniffio==1.3.1 sse-starlette==3.5.0 starlette==1.7.0 tenacity==9.1.4 textual==8.2.8 -typer==0.27.3 +truststore==0.10.4 typing-inspection==0.4.4 typing_extensions==4.16.0 urllib3==2.8.0 diff --git a/docs/updates.md b/docs/updates.md index cad6c7b..ad8279c 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -45,10 +45,10 @@ Detection is not a guess about how you *should* have installed it: This release moves the SDK dependency to `mcp>=2.3,<3` (the server now builds on `MCPServer`). -1. Pip or git install: run `/bin/pip install -e .` (or the pip command from the table above), then `systemctl restart beaconmcp`. +1. Git checkout: `beaconmcp_self_update` handles it (the pip step reinstalls with the new `constraints.txt`, the startup gate runs under mcp 2.x before the restart). By hand: `/bin/pip install -e . -c constraints.txt`, then `systemctl restart beaconmcp`. 2. Docker: `docker compose pull` then `docker compose up -d`, or rebuild the image if you build it yourself. -3. Run `beaconmcp validate-config`: it works unchanged, your `beaconmcp.yaml` needs no edit. -4. Roll back by checking out the previous tag and reinstalling, then restart. +3. Run `beaconmcp check-startup`: your `beaconmcp.yaml` needs no edit, and this proves the server starts with it. +4. Roll back by checking out the previous commit (or tag) and reinstalling with `-c constraints.txt`, then restart. ## MCP tools