Skip to content

feat: move to the mcp 2.x SDK (MCPServer) - #44

Closed
Ailcope wants to merge 6 commits into
Showdown76py:mainfrom
Ailcope:feat/mcp-sdk-2
Closed

Ailcope wants to merge 6 commits into
Showdown76py:mainfrom
Ailcope:feat/mcp-sdk-2

Conversation

@Ailcope

@Ailcope Ailcope commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator

Why

The dedicated migration PR promised when #39 was closed. mcp 1.x is in maintenance mode (security fixes only) and the <2 pin from #32 was a stopgap: 2.0 renamed mcp.server.fastmcp.FastMCP to mcp.server.mcpserver.MCPServer and changed enough around it that a range bump could not work. This moves the project to mcp>=2.3,<3 and ports every SDK touchpoint, with the three traps that a mechanical rename would have shipped:

  1. Public hosts answered 421. Transport parameters left the MCPServer constructor. With no explicit transport_security, the 2.x streamable_http_app() auto-enables a loopback-only allowlist (127.0.0.1:*, localhost:*), so every request from claude.ai, ChatGPT or Le Chat would have been 421 Misdirected Request. The settings are now built in server.py and passed to the app factory.
  2. MCP Apps panels silently gone. The dashboard's AppsClientSession injected the io.modelcontextprotocol/ui capability by testing request.root on the outgoing InitializeRequest. 2.x request types are not RootModels any more, so the check would have been False forever: no capability advertised, no panels, no error. ClientSession(extensions=...) is the 2.x way and replaces the subclass.
  3. Tool errors turned generic. In 2.x only ToolError keeps its message for the model; any other exception is reported as "Error executing tool X" and the text stays in the server log. _metric_tool now converts unexpected exceptions to ToolError(str(e)), so "node not found" style messages still reach the client.

Changes

  • pyproject.toml: mcp>=2.3,<3, [cli] extra dropped (it only pulled typer for a CLI the server never uses). httpx stays for Redfish; the SDK's httpx2 is a separate package and coexists.
  • server.py + the 9 tool modules: MCPServer, Icon(mime_type=), version= from package metadata (2.x defaults to ""), module-level transport_security. _metric_tool keeps its async wrapper (the SDK only offloads sync functions, so there is no double offload), re-raises ToolError/MCPError as is, converts the rest, and counts cancelled calls as cancelled rather than ok.
  • __main__.py (one block): mcp.streamable_http_app(transport_security=...), parent lifespan on the public mcp.session_manager.run() instead of the child router's private lifespan.
  • dashboard/mcp_bridge.py, dashboard/chat.py: streamable_http_client with an httpx2.AsyncClient carrying the bearer and Timeout(connect=30, read=300); chat.py reuses open_session instead of its own copy of the handshake; snake_case reads (is_error, input_schema, mime_type, structured_content). Wire keys sent to the browser and to Gemini are unchanged, bridge.js and app.py untouched.
  • CI: the build and docker jobs now import beaconmcp.server (and the bridge) from the wheel and inside the image. --help and validate-config never import the SDK, which is why the 2.0 break only showed up in the test job.
  • Docs: docs/updates.md upgrade note (pip reinstall + restart, Docker pull, rollback), MCPServer wording in docs/dashboard.md and CLAUDE.md.

Known and left alone

  • SDK defaults kept, not exposed in beaconmcp.yaml: 4 MiB request body limit, session_idle_timeout 1800 s, max_sessions 10000. No tool takes more than 4 MiB today; worth a config knob if file transfers ever go through /mcp.
  • OpenTelemetry is on by default in 2.x. Without an exporter nothing leaves the process; the traceparent it puts in _meta is left as is.
  • The MCP Apps server side (proxmox/panel.py, apps/*, the ui/initialize handshake from feat: interactive panels via the MCP Apps extension #34) stays hand-rolled; the SDK's Apps extension class is not used.
  • auth.py:68 has a comment that still says "FastMCP". Left alone on purpose: feat(auth): oauth refresh tokens and persisted oauth tokens #40 rewrites that area and I'd rather not create a conflict for one word.
  • docs/superpowers/specs/2026-04-16-beaconmcp-design.md mentions FastMCP as history; untouched.
  • Protocol version: a client proposing 2025-06-18 gets 2025-06-18 back (tested); 2.x can also serve 2026-07-28 to clients that ask for it. No behaviour change for the current connectors.

Related

Tests

  • 517 passed under mcp 2.3.0, ruff check clean, CI on 3.11/3.12/3.13 (the suite was 537 on main; the two AppsClientSession tests are replaced by two new ones and the panel/bridge tests were ported, see the test commit).
  • New tests/test_mcp_http_stack.py drives the real app _run_http builds, in-process through the parent lifespan: 401 + resource_metadata without a bearer, initialize 200 with Mcp-Session-Id, tools/list with type: object on every schema, tools/call, the /mcp/c/<slug> rewrite, and 421 on a foreign Host.
  • Live run against beaconmcp serve on this branch: client_credentials token, initialize 200 (negotiated 2025-06-18), /mcp/c/<slug> 200, Host: evil.example.net 421, then the dashboard bridge end to end (open_session over httpx2 with the ui extension, list_tools, list_resources, call_tool).
  • Not exercised here: a real claude.ai / ChatGPT connector against this build. I'll run it on my instance once this is on main and report back.

The 1.x line is in maintenance mode (security fixes only) and the `<2`
pin from Showdown76py#32 was always meant to be temporary. Dependabot's range bump
(Showdown76py#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.
`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=<package 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.
`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.
- `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/<slug>` 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.
`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.
- `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.
@Ailcope Ailcope added the enhancement New feature or request label Oct 11, 2026
@Ailcope Ailcope self-assigned this Oct 11, 2026

Copy link
Copy Markdown
Owner

Thanks, the port holds up: same behaviour as main on a real server (tools, error messages, Host/Origin checks, dashboard bridge), driven by a 1.x client.

Two things blocked merging it as is, so I carried it in #48 with your six commits untouched:

This PR will close when #48 merges.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants