Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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'
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
12 changes: 6 additions & 6 deletions constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
9 changes: 9 additions & 0 deletions docs/updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<venv>/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:
Expand Down
7 changes: 2 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 10 additions & 8 deletions src/beaconmcp/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 = []
Expand Down
4 changes: 2 additions & 2 deletions src/beaconmcp/bmc/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
82 changes: 41 additions & 41 deletions src/beaconmcp/dashboard/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading
Loading