Skip to content

feat(mcp-hmr): support MCP Python SDK 2.x - #128

Open
CNSeniorious000 wants to merge 14 commits into
readmefrom
mcp-v2-clean
Open

feat(mcp-hmr): support MCP Python SDK 2.x#128
CNSeniorious000 wants to merge 14 commits into
readmefrom
mcp-v2-clean

Conversation

@CNSeniorious000

Copy link
Copy Markdown
Member

mcp 1.x and 2.x are the same distribution and cannot be installed side by side, so mcp-hmr now adapts at runtime to whichever generation is present.

What changed

The backend is picked from the target instance at first load, not from whatever mcp happens to be importable — a FastMCP still exists on mcp 2.x (that is what fastmcp 4 runs on) and must keep the proxy path.

target backend how list-changed reaches clients
MCPServer (mcp 2.x) registry swap into a stable outer server subscription bus we own
everything else FastMCP proxy ServerSession notifications

MCPServer gets a swap rather than a proxy because that SDK has neither mount nor proxy. It talks Python, not MCP, so only the tool/resource/prompt registries are swapped — extensions, custom routes and middleware added on reload won't take effect, and HTTP transport gets no CORS since its runners take no middleware. All documented in the README.

Runner kwargs are now filtered by signature on every path, not just the MCPServer one. The signatures drift a lot: log_level only reached FastMCP.run_stdio_async in fastmcp 2.13, and MCPServer's runners take a far narrower set and name the route path per transport.

fastmcp is dropped as a hard dependency — it is only needed for the proxy path, and it pins mcp<2, which would have made 2.x unreachable.

Verified

End-to-end through the CLI with a real client, editing the server module mid-connection and asserting the tool list updates:

fastmcp mcp target stdio http
2.12.0 1.29.0 fastmcp.FastMCP, mcp.server.fastmcp.FastMCP
2.14.7 1.29.0 fastmcp.FastMCP, mcp.server.fastmcp.FastMCP
3.4.5 1.29.0 fastmcp.FastMCP, mcp.server.fastmcp.FastMCP
4.0.0b1 2.0.0 MCPServer
4.0.0b1 2.0.0 fastmcp.FastMCP
2.14.7 1.29.0 mcp_use.MCPServer

Note

The one failure reproduces with plain fastmcp and no mcp-hmr in the picture — 4.0.0b1 rejects the 2026-07-28 envelope on its own proxy connections. Upstream beta bug; worth re-checking at GA.

Supersedes #127.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • When fastmcp is not installed on mcp 1.x, proxy_backend() will raise an ImportError; consider handling this explicitly (with a clear error message or fallback) so users see a friendlier failure mode than a hard import error.
  • The use of print() to stderr in _mcpserver_kwargs is a bit surprising for a library; consider switching to the standard logging facilities or surfacing this warning through a more structured mechanism.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- When `fastmcp` is not installed on mcp 1.x, `proxy_backend()` will raise an `ImportError`; consider handling this explicitly (with a clear error message or fallback) so users see a friendlier failure mode than a hard import error.
- The use of `print()` to stderr in `_mcpserver_kwargs` is a bit surprising for a library; consider switching to the standard logging facilities or surfacing this warning through a more structured mechanism.

## Individual Comments

### Comment 1
<location path="packages/mcp-hmr/mcp_hmr.py" line_range="193-196" />
<code_context>
     lock = Lock()

-    async def using(app: FastMCP | mcp.server.FastMCP, stop_event: Event, finish_event: Event):
+    async def using(app, stop_event: Event, finish_event: Event):
         async with lock:
             with mount(app):
-                for session in active_sessions:
-                    tg.create_task(_notify_list_changed(session))
+                tg.create_task(notify())
                 await stop_event.wait()
                 finish_event.set()
</code_context>
<issue_to_address>
**issue (bug_risk):** TaskGroup variable `tg` is used out of scope in `using`, which will raise at runtime.

`tg.create_task(notify())` references `tg` that is only defined in the outer scope (e.g., inside `run_with_patched_session`), so `using` will raise a `NameError` at runtime and notifications will never be scheduled. Please either pass `tg` into `using`, create a new `TaskGroup` inside `using`, or move the notify scheduling into the scope where `tg` is defined.
</issue_to_address>

### Comment 2
<location path="packages/mcp-hmr/mcp_hmr.py" line_range="187" />
<code_context>
+    from reactivity.hmr.core import HMR_CONTEXT, AsyncReloader, _loader
+    from reactivity.hmr.hooks import call_post_reload_hooks, call_pre_reload_hooks
+
+    base_app: Any = ...  # base_app / mount / notify are picked from the target itself on first load
+    mount: Any = ...
+    notify: Any = ...
</code_context>
<issue_to_address>
**issue (complexity):** Consider encapsulating the backend state and MCPServer-specific runner logic into small helper abstractions to avoid sentinel variables and reduce cognitive load in `mcp_server` and `run_with_hmr`.

You can reduce most of the added complexity without changing behavior by introducing a tiny `Backend` container and a small runner helper. This keeps your existing `proxy_backend` / `swap_backend` logic but removes the sentinel `...`/`nonlocal` juggling and centralizes backend behavior.

### 1. Replace `base_app/mount/notify` sentinels with a `Backend` object

Right now:

```python
base_app: Any = ...  # base_app / mount / notify are picked from the target itself on first load!
mount: Any = ...
notify: Any = ...

...

@async_effect(context=HMR_CONTEXT, call_immediately=False)
async def main():
    nonlocal stop_event, finish_event, base_app, mount, notify

    ...
    app = get_app()

    if base_app is ...:
        base_app, mount, notify = pick_backend(app)

    tg.create_task(using(app, stop_event := Event(), finish_event := Event()))
```

You can encapsulate these into a single object and avoid `Any = ...` and multi-variable `nonlocal`:

```python
from dataclasses import dataclass
from typing import Awaitable, Callable, ContextManager

@dataclass
class Backend:
    base_app: Any
    mount: Callable[[Any], ContextManager[None]]
    notify: Callable[[], Awaitable[None]]
```

Change your backend factories to return a `Backend` instead of a triple:

```python
def proxy_backend() -> Backend:
    ...
    return Backend(base_app=base_app, mount=mount, notify=notify)

def swap_backend() -> Backend:
    ...
    return Backend(base_app=base_app, mount=mount, notify=notify)

def pick_backend(app) -> Backend:
    ...
    return swap_backend() if isinstance(app, MCPServer) else proxy_backend()
```

Then `mcp_server` can hold a single `backend` (or `None`) and a single nonlocal:

```python
def mcp_server(target: str):
    ...
    backend: Backend | None = None

    lock = Lock()

    async def using(app, stop_event: Event, finish_event: Event):
        async with lock:
            assert backend is not None
            with backend.mount(app):
                tg.create_task(backend.notify())
                await stop_event.wait()
                finish_event.set()

    ...

    @async_effect(context=HMR_CONTEXT, call_immediately=False)
    async def main():
        nonlocal stop_event, finish_event, backend

        if stop_event is not None:
            stop_event.set()
            await finish_event.wait()

        app = get_app()

        if backend is None:
            backend = pick_backend(app)

        tg.create_task(using(app, stop_event := Event(), finish_event := Event()))

    ...

    @asynccontextmanager
    async def _():
        nonlocal tg
        async with TaskGroup() as tg, Reloader():
            assert backend is not None
            yield backend.base_app

    return _()
```

This keeps all the existing behavior (including how `mount` and `notify` differ between backends) but:

- removes the `Any = ...` sentinel pattern,  
- collapses three separate pieces of shared state into one,  
- makes `mcp_server` only aware of a single `Backend` object.

### 2. Extract MCPServer-specific running into a helper

`run_with_hmr` currently mixes MCPServer-specific and FastMCP-specific logic in one large function. You can keep the semantics while delegating the MCPServer branch into a small helper:

```python
def _run_mcpserver(mcp, transport: str, kwargs: dict):
    if transport == "stdio":
        return mcp.run_stdio_async()
    if transport == "sse":
        return mcp.run_sse_async(**_mcpserver_kwargs(mcp.run_sse_async, kwargs, "sse_path"))
    return mcp.run_streamable_http_async(
        **_mcpserver_kwargs(mcp.run_streamable_http_async, kwargs, "streamable_http_path")
    )
```

Then `run_with_hmr` becomes:

```python
async def run_with_hmr(target: str, log_level: str | None = None, transport="stdio", **kwargs):
    async with mcp_server(target) as mcp:
        if not hasattr(mcp, "run_async"):  # MCPServer
            return await _run_mcpserver(mcp, transport, kwargs)

        kwargs |= {"log_level": log_level}
        match transport:
            case "stdio":
                await mcp.run_stdio_async(**_supported(mcp.run_stdio_async, kwargs | {"show_banner": False}))
            case "http" | "streamable-http":
                await mcp.run_http_async(**_supported(mcp.run_http_async, kwargs))
            case "sse":
                if hasattr(mcp, "run_sse_async"):
                    await mcp.run_sse_async(**_supported(mcp.run_sse_async, kwargs))
                else:
                    await mcp.run_http_async(transport="sse", **_supported(mcp.run_http_async, kwargs))
            case _:
                await mcp.run_async(transport, **_supported(mcp.run_async, kwargs))
```

This keeps all compatibility logic but reduces the cognitive load in your primary entrypoint: `run_with_hmr` no longer has to spell out every MCPServer detail inline.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread packages/mcp-hmr/mcp_hmr.py Outdated
Comment thread packages/mcp-hmr/mcp_hmr.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
packages/mcp-hmr/mcp_hmr.py (3)

79-89: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

providers[-1] assumes mount appends exactly one provider.

The code takes the last element as the provider it just mounted. The lock in mcp_server serializes mounts, so this holds today. It breaks quietly if a future mount appends more than one entry, or if another caller mounts on base_app. Diffing the list before and after is a cheap way to stay exact.

♻️ Proposed change
         `@contextmanager`
         def mount(app):
+            before = len(base_app.providers)  # type: ignore
             base_app.mount(create_proxy(ProxyClient(app)))  # type: ignore
-            provider = base_app.providers[-1]  # type: ignore
+            added = base_app.providers[before:]  # type: ignore
             try:
                 yield
             finally:  # unmount
-                base_app.providers.remove(provider)  # type: ignore
+                for provider in added:
+                    base_app.providers.remove(provider)  # type: ignore
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/mcp-hmr/mcp_hmr.py` around lines 79 - 89, Update the mount context
manager to snapshot base_app.providers before create_proxy(...).mount, then
identify the newly added provider(s) by diffing the post-mount list against that
snapshot; remove exactly the provider entry or entries introduced by this mount
during cleanup instead of assuming providers[-1].

280-289: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Backend detection by hasattr re-derives what pick_backend already knew.

pick_backend decides between swap_backend and proxy_backend, then that decision is thrown away and rebuilt here from hasattr(mcp, "run_async"). The two can disagree if MCPServer ever grows a run_async, and the failure would be a confusing kwargs mismatch rather than a clear error. Exporting the backend kind alongside base_app would keep one source of truth.

Non-blocking; the current check works against today's SDKs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/mcp-hmr/mcp_hmr.py` around lines 280 - 289, Update the
backend-selection flow around pick_backend and run_with_hmr so pick_backend
returns the selected backend kind alongside base_app, then branch in
run_with_hmr using that value instead of hasattr(mcp, "run_async"). Preserve the
existing runner and transport-specific behavior while keeping backend detection
as the single source of truth.

44-66: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider a clearer error when fastmcp is absent.

fastmcp is not a declared dependency (see packages/mcp-hmr/pyproject.toml). On mcp 1.x targets, or a non-MCPServer target on 2.x, line 50 raises a bare ModuleNotFoundError: No module named 'fastmcp'. The README explains the requirement, but the CLI user sees only a traceback. Wrap the import and raise a message that names the fix.

♻️ Proposed message
-    from fastmcp import FastMCP
+    try:
+        from fastmcp import FastMCP
+    except ImportError as e:  # fastmcp is not a hard dependency
+        raise ImportError("mcp-hmr fronts this target with a FastMCP proxy. Run `pip install fastmcp`.") from e
     from mcp.server.session import ServerSession
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/mcp-hmr/mcp_hmr.py` around lines 44 - 66, Update proxy_backend to
catch a missing fastmcp import before its version-specific imports and raise a
clear user-facing error that states fastmcp is required for proxying and
instructs the user to install it. Preserve the existing fastmcp 3 versus legacy
import fallback behavior once the dependency is available.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/mcp-hmr/mcp_hmr.py`:
- Around line 261-268: The _supported function must preserve extra options for
runners that accept arbitrary keyword arguments. Inspect the runner signature
for a VAR_KEYWORD parameter, retain all non-None kwargs when present, and
otherwise continue filtering to declared parameter names so named options remain
supported.
- Around line 220-231: Update the initialization flow around get_app() in the
enclosing reloader callback to handle first-load backend failures explicitly:
provide the existing proxy_backend() fallback, or re-raise the original
exception when get_app() fails, so base_app is never left as Ellipsis before
run_with_hmr invokes the server.

In `@packages/mcp-hmr/README.md`:
- Line 16: Update the README wording to state that FastMCP proxying, and
therefore the requirement for importable fastmcp, applies to all supported mcp
generations whenever the target is not an mcp.server.MCPServer, including
FastMCP targets on mcp 2.x. Also capitalize “Python” in the “official Python
SDK” wording.

---

Nitpick comments:
In `@packages/mcp-hmr/mcp_hmr.py`:
- Around line 79-89: Update the mount context manager to snapshot
base_app.providers before create_proxy(...).mount, then identify the newly added
provider(s) by diffing the post-mount list against that snapshot; remove exactly
the provider entry or entries introduced by this mount during cleanup instead of
assuming providers[-1].
- Around line 280-289: Update the backend-selection flow around pick_backend and
run_with_hmr so pick_backend returns the selected backend kind alongside
base_app, then branch in run_with_hmr using that value instead of hasattr(mcp,
"run_async"). Preserve the existing runner and transport-specific behavior while
keeping backend detection as the single source of truth.
- Around line 44-66: Update proxy_backend to catch a missing fastmcp import
before its version-specific imports and raise a clear user-facing error that
states fastmcp is required for proxying and instructs the user to install it.
Preserve the existing fastmcp 3 versus legacy import fallback behavior once the
dependency is available.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b25fb14f-3966-47d1-a35d-5be4c1dda43f

📥 Commits

Reviewing files that changed from the base of the PR and between df49298 and 1208e85.

📒 Files selected for processing (3)
  • packages/mcp-hmr/README.md
  • packages/mcp-hmr/mcp_hmr.py
  • packages/mcp-hmr/pyproject.toml
💤 Files with no reviewable changes (1)
  • packages/mcp-hmr/pyproject.toml

Comment thread packages/mcp-hmr/mcp_hmr.py
Comment thread packages/mcp-hmr/mcp_hmr.py Outdated
Comment thread packages/mcp-hmr/README.md Outdated
@CNSeniorious000
CNSeniorious000 force-pushed the mcp-v2-clean branch 2 times, most recently from cfa34c8 to 0dbdfad Compare August 2, 2026 08:06
`mcp` 1.x and 2.x are the same distribution and cannot coexist, so the
backend is now picked from the target instance at first load instead of
from whatever `mcp` is installed:

- `MCPServer` targets swap their tool/resource/prompt registries into a
  stable outer server, since that SDK has neither mount nor proxy, and
  publish list-changed events on a subscription bus we own.
- everything else keeps the FastMCP proxy path, including a `FastMCP`
  running on top of `mcp` 2.x.

Runner kwargs are filtered by signature on every path, as the signatures
drift across both SDK generations. The `fastmcp` bound moves from `<3` to
`<4`: 2.2.0 pins `mcp<2.0.0`, so `<3` kept 2.x out of reach.
@promplate promplate deleted a comment from gemini-code-assist Bot Aug 4, 2026
@promplate promplate deleted a comment from chatgpt-codex-connector Bot Aug 4, 2026
@promplate promplate deleted a comment from coderabbitai Bot Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant