From c2675172bf024e00b8a74d52270a1593ac2098a7 Mon Sep 17 00:00:00 2001 From: Muspi Merol Date: Fri, 17 Oct 2025 19:12:32 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20add=20initial=20implementation=20of?= =?UTF-8?q?=20`mcp-hmr`=20=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/mcp-hmr/README.md | 0 packages/mcp-hmr/mcp_hmr.py | 114 ++++++++++++++++++++++++++++++++ packages/mcp-hmr/pyproject.toml | 33 +++++++++ 3 files changed, 147 insertions(+) create mode 100644 packages/mcp-hmr/README.md create mode 100644 packages/mcp-hmr/mcp_hmr.py create mode 100644 packages/mcp-hmr/pyproject.toml diff --git a/packages/mcp-hmr/README.md b/packages/mcp-hmr/README.md new file mode 100644 index 0000000..e69de29 diff --git a/packages/mcp-hmr/mcp_hmr.py b/packages/mcp-hmr/mcp_hmr.py new file mode 100644 index 0000000..7b42eef --- /dev/null +++ b/packages/mcp-hmr/mcp_hmr.py @@ -0,0 +1,114 @@ +import sys + +__version__ = "0.0.1" + + +async def run_with_hmr(target: str): + 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 + + base_app = FastMCP(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 + for mounted_server in 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 + + 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 + + @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 + + 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) + + +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("--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: '{args.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(args.target)) + + +if __name__ == "__main__": + cli() diff --git a/packages/mcp-hmr/pyproject.toml b/packages/mcp-hmr/pyproject.toml new file mode 100644 index 0000000..a08b012 --- /dev/null +++ b/packages/mcp-hmr/pyproject.toml @@ -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 = "MIT" +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "Operating System :: OS Independent", +] +dependencies = [ + "fastmcp>=2.2.0,<3", + "hmr~=0.7.0", +] +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" + From f5fdbd118784093176e0953ef1e7834f58ff3316 Mon Sep 17 00:00:00 2001 From: Muspi Merol Date: Sat, 18 Oct 2025 02:34:36 +0800 Subject: [PATCH 2/9] Update packages/mcp-hmr/mcp_hmr.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/mcp-hmr/mcp_hmr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp-hmr/mcp_hmr.py b/packages/mcp-hmr/mcp_hmr.py index 7b42eef..304d727 100644 --- a/packages/mcp-hmr/mcp_hmr.py +++ b/packages/mcp-hmr/mcp_hmr.py @@ -97,7 +97,7 @@ def cli(argv: list[str] = sys.argv[1:]): 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: '{args.target}'") + 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 45008ee1d1f20df53fc71d0cfa82569ca5e1c36d Mon Sep 17 00:00:00 2001 From: Muspi Merol Date: Sat, 18 Oct 2025 02:36:36 +0800 Subject: [PATCH 3/9] Update packages/mcp-hmr/pyproject.toml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- packages/mcp-hmr/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp-hmr/pyproject.toml b/packages/mcp-hmr/pyproject.toml index a08b012..b6a993c 100644 --- a/packages/mcp-hmr/pyproject.toml +++ b/packages/mcp-hmr/pyproject.toml @@ -5,7 +5,7 @@ readme = "README.md" requires-python = ">=3.12" keywords = ["mcp", "hot-reload", "hmr", "reload", "server"] authors = [{ name = "Muspi Merol", email = "me@promplate.dev" }] -license = "MIT" +license = { text = "MIT" } classifiers = [ "Development Status :: 2 - Pre-Alpha", "Intended Audience :: Developers", From 68ca61ef20997f9afcdb81845bcd8d66691547f6 Mon Sep 17 00:00:00 2001 From: Muspi Merol Date: Sat, 18 Oct 2025 02:36:56 +0800 Subject: [PATCH 4/9] Update packages/mcp-hmr/mcp_hmr.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- packages/mcp-hmr/mcp_hmr.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/mcp-hmr/mcp_hmr.py b/packages/mcp-hmr/mcp_hmr.py index 304d727..616f972 100644 --- a/packages/mcp-hmr/mcp_hmr.py +++ b/packages/mcp-hmr/mcp_hmr.py @@ -25,12 +25,13 @@ def mount(app: FastMCP | mcp.server.FastMCP): try: yield finally: # unmount - for mounted_server in base_app._mounted_servers: # noqa: SLF001 + 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() From 3a1b25b4213953fe53c9992b3619e12730a797cc Mon Sep 17 00:00:00 2001 From: Muspi Merol Date: Sat, 18 Oct 2025 02:54:48 +0800 Subject: [PATCH 5/9] chore: add `mcp-hmr` to workspace dependencies --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 4fcae9d..0708045 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ dependencies = [ "fastapi-reloader", "hmr~=0.7.0", "hmr-daemon", + "mcp-hmr", "ruff~=0.14.0", "uvicorn-hmr", ] @@ -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" From adeb32a8026d1f674d4ca58b360fa0bf6b1e80f4 Mon Sep 17 00:00:00 2001 From: Muspi Merol Date: Sat, 18 Oct 2025 03:18:44 +0800 Subject: [PATCH 6/9] feat: add a `README.md` for `mcp-hmr` --- packages/mcp-hmr/README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/mcp-hmr/README.md b/packages/mcp-hmr/README.md index e69de29..df3d6c8 100644 --- a/packages/mcp-hmr/README.md +++ b/packages/mcp-hmr/README.md @@ -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. From 74e0ca34ba2161c199995a84390e46b6a4939468 Mon Sep 17 00:00:00 2001 From: Muspi Merol Date: Sat, 18 Oct 2025 03:22:27 +0800 Subject: [PATCH 7/9] fix(ci): publish `mcp-hmr` too --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d2792e..2faf559 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,7 @@ jobs: - uvicorn-hmr - fastapi-reloader - hmr-daemon + - mcp-hmr fail-fast: false needs: [typos, check] permissions: From baeb8135632fe2db838819d3aa26e83202296c2f Mon Sep 17 00:00:00 2001 From: Muspi Merol Date: Sat, 18 Oct 2025 16:38:54 +0800 Subject: [PATCH 8/9] chore: give the `base_app` a name --- packages/mcp-hmr/mcp_hmr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp-hmr/mcp_hmr.py b/packages/mcp-hmr/mcp_hmr.py index 616f972..2e45cbc 100644 --- a/packages/mcp-hmr/mcp_hmr.py +++ b/packages/mcp-hmr/mcp_hmr.py @@ -17,7 +17,7 @@ async def run_with_hmr(target: str): from reactivity.hmr.core import HMR_CONTEXT, AsyncReloader from reactivity.hmr.hooks import call_post_reload_hooks, call_pre_reload_hooks - base_app = FastMCP(include_fastmcp_meta=False) + base_app = FastMCP(name="proxy", include_fastmcp_meta=False) @contextmanager def mount(app: FastMCP | mcp.server.FastMCP): From fad2b48b7df307b28038b6025a72e3a0519abd0e Mon Sep 17 00:00:00 2001 From: Muspi Merol Date: Sat, 18 Oct 2025 18:04:01 +0800 Subject: [PATCH 9/9] feat: add `--log-level` option --- packages/mcp-hmr/mcp_hmr.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/mcp-hmr/mcp_hmr.py b/packages/mcp-hmr/mcp_hmr.py index 2e45cbc..685b13e 100644 --- a/packages/mcp-hmr/mcp_hmr.py +++ b/packages/mcp-hmr/mcp_hmr.py @@ -3,7 +3,7 @@ __version__ = "0.0.1" -async def run_with_hmr(target: str): +async def run_with_hmr(target: str, log_level: str | None = None): module, attr = target.split(":") from asyncio import Event, Lock, create_task @@ -79,7 +79,7 @@ async def __aexit__(self, *_): await self.reloader_task async with Reloader(): - await base_app.run_stdio_async(show_banner=False) + await base_app.run_stdio_async(show_banner=False, log_level=log_level) def cli(argv: list[str] = sys.argv[1:]): @@ -87,6 +87,7 @@ def cli(argv: list[str] = sys.argv[1:]): 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: @@ -108,7 +109,7 @@ def cli(argv: list[str] = sys.argv[1:]): sys.path.append(cwd) with suppress(KeyboardInterrupt): - run(run_with_hmr(args.target)) + run(run_with_hmr(target, args.log_level)) if __name__ == "__main__":