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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ jobs:
- uvicorn-hmr
- fastapi-reloader
- hmr-daemon
- mcp-hmr
fail-fast: false
needs: [typos, check]
permissions:
Expand Down
26 changes: 26 additions & 0 deletions packages/mcp-hmr/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# mcp-hmr

[![PyPI - Version](https://img.shields.io/pypi/v/mcp-hmr)](https://pypi.org/project/mcp-hmr/)
[![PyPI - Downloads](https://img.shields.io/pypi/dw/mcp-hmr)](https://pepy.tech/projects/mcp-hmr)

Provides [Hot Module Reloading](https://pyth-on-line.promplate.dev/hmr) for MCP/FastMCP servers.

It acts as **a drop-in replacement for `mcp run module:app` or `fastmcp run module:app`.** Both [FastMCP v2](https://github.com/jlowin/fastmcp) and the [official python SDK](https://github.com/modelcontextprotocol/python-sdk) are supported.

## Usage

If your server instance is named `app` in `path/to/main.py`, you can run:

```sh
mcp-hmr path.to.main:app
```

Which will be equivalent to the following code but with [HMR](https://github.com/promplate/hmr) enabled:

```py
from path.to.main import app

app.run("stdio")
```

Now, whenever you save changes to your source code, the server will automatically reload without dropping the connection to the client.
116 changes: 116 additions & 0 deletions packages/mcp-hmr/mcp_hmr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import sys

__version__ = "0.0.1"


async def run_with_hmr(target: str, log_level: str | None = None):
module, attr = target.split(":")

from asyncio import Event, Lock, create_task
from contextlib import contextmanager
from importlib import import_module

import mcp.server
from fastmcp import FastMCP
from fastmcp.server.proxy import ProxyClient
from reactivity import async_effect, derived
from reactivity.hmr.core import HMR_CONTEXT, AsyncReloader
from reactivity.hmr.hooks import call_post_reload_hooks, call_pre_reload_hooks

Comment thread
CNSeniorious000 marked this conversation as resolved.
base_app = FastMCP(name="proxy", include_fastmcp_meta=False)

@contextmanager
def mount(app: FastMCP | mcp.server.FastMCP):
base_app.mount(proxy := FastMCP.as_proxy(ProxyClient(app)), as_proxy=False)
try:
yield
finally: # unmount
Comment thread
CNSeniorious000 marked this conversation as resolved.
for mounted_server in list(base_app._mounted_servers): # noqa: SLF001
if mounted_server.server is proxy:
base_app._mounted_servers.remove(mounted_server) # noqa: SLF001
base_app._tool_manager._mounted_servers.remove(mounted_server) # noqa: SLF001
base_app._resource_manager._mounted_servers.remove(mounted_server) # noqa: SLF001
base_app._prompt_manager._mounted_servers.remove(mounted_server) # noqa: SLF001
break

lock = Lock()

async def using(app: FastMCP | mcp.server.FastMCP, stop_event: Event, finish_event: Event):
async with lock:
with mount(app):
await stop_event.wait()
finish_event.set()

@derived(context=HMR_CONTEXT)
def get_app():
return getattr(import_module(module), attr)

stop_event: Event | None = None
finish_event: Event = ... # type: ignore
Comment thread
CNSeniorious000 marked this conversation as resolved.
Comment thread
CNSeniorious000 marked this conversation as resolved.

Comment thread
CNSeniorious000 marked this conversation as resolved.
@async_effect(context=HMR_CONTEXT, call_immediately=False)
async def main():
nonlocal stop_event, finish_event

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

app = get_app()

create_task(using(app, stop_event := Event(), finish_event := Event())) # noqa: RUF006
Comment thread
CNSeniorious000 marked this conversation as resolved.

class Reloader(AsyncReloader):
def __init__(self):
super().__init__("")
self.error_filter.exclude_filenames.add(__file__)

async def __aenter__(self):
call_pre_reload_hooks()
try:
await main()
finally:
call_post_reload_hooks()
self.reloader_task = create_task(self.start_watching())

async def __aexit__(self, *_):
self.stop_watching()
main.dispose()
await self.reloader_task

async with Reloader():
await base_app.run_stdio_async(show_banner=False, log_level=log_level)


def cli(argv: list[str] = sys.argv[1:]):
from argparse import SUPPRESS, ArgumentParser

parser = ArgumentParser(prog="mcp-hmr", description="Hot Reloading for MCP Servers • Automatically reload on code changes")
parser.add_argument("target", help="The import path of the FastMCP instance, e.g. `main:app` means `from main import app`", metavar="module:attr")
parser.add_argument("-l", "--log-level", choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], type=str.upper, default=None)
parser.add_argument("--version", action="version", version=f"mcp-hmr {__version__}", help=SUPPRESS)

if not argv:
parser.print_help()
return

args = parser.parse_args(argv)

target: str = args.target

if target.count(":") != 1 or target.startswith(":") or target.endswith(":"):
parser.exit(1, f"The target argument must be in the format 'module:attr', e.g. 'main:app'. Got: '{target}'")

from asyncio import run
from contextlib import suppress
from pathlib import Path

if (cwd := str(Path.cwd())) not in sys.path:
sys.path.append(cwd)

with suppress(KeyboardInterrupt):
run(run_with_hmr(target, args.log_level))


if __name__ == "__main__":
cli()
33 changes: 33 additions & 0 deletions packages/mcp-hmr/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
[project]
name = "mcp-hmr"
description = "Hot Reloading for MCP Servers"
readme = "README.md"
requires-python = ">=3.12"
keywords = ["mcp", "hot-reload", "hmr", "reload", "server"]
authors = [{ name = "Muspi Merol", email = "me@promplate.dev" }]
license = { text = "MIT" }
classifiers = [
"Development Status :: 2 - Pre-Alpha",
"Intended Audience :: Developers",
"Operating System :: OS Independent",
]
dependencies = [
"fastmcp>=2.2.0,<3",
"hmr~=0.7.0",
]
Comment thread
CNSeniorious000 marked this conversation as resolved.
dynamic = ["version"]

[tool.pdm.version]
source = "file"
path = "mcp_hmr.py"

[project.scripts]
mcp-hmr = "mcp_hmr:cli"

[project.urls]
Homepage = "https://github.com/promplate/hmr"

[build-system]
requires = ["pdm-backend"]
build-backend = "pdm.backend"

2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ dependencies = [
"fastapi-reloader",
"hmr~=0.7.0",
"hmr-daemon",
"mcp-hmr",
"ruff~=0.14.0",
"uvicorn-hmr",
]
Expand All @@ -17,6 +18,7 @@ members = ["examples/*", "packages/*"]
uvicorn-hmr = { workspace = true }
fastapi-reloader = { workspace = true }
hmr-daemon = { workspace = true }
mcp-hmr = { workspace = true }

[tool.pyright]
typeCheckingMode = "standard"
Expand Down