diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c79db6..e3051c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,6 +112,21 @@ jobs: /tmp/smoke/bin/pip install dist/*.whl -c constraints.txt /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: @@ -131,3 +146,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' 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/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/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 e9deba1..ad8279c 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -41,6 +41,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. 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 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 Two tools, registered on every deployment shape: 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", diff --git a/src/beaconmcp/__main__.py b/src/beaconmcp/__main__.py index 80c86be..0e29680 100644 --- a/src/beaconmcp/__main__.py +++ b/src/beaconmcp/__main__.py @@ -1204,7 +1204,7 @@ def _run_http(mcp, host: str, port: int, *, serve: bool = True): current_bearer_token, ) from .ratelimit import RateLimiter, TotpLockout, client_ip, forwarded_host - from .server import config + from .server import config, transport_security from .metrics import REGISTRY, auth_events, http_requests @@ -2316,16 +2316,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/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 diff --git a/src/beaconmcp/maintenance/tools.py b/src/beaconmcp/maintenance/tools.py index aa644c5..be9526b 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 76274f9..bbf17e1 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 @@ -165,7 +165,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 fd009d3..a1048ad 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() diff --git a/tests/test_dashboard_mcp_apps.py b/tests/test_dashboard_mcp_apps.py index 9dba410..536e350 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 880ddab..629e74f 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -1296,7 +1296,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] = {}