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: diff --git a/packages/mcp-hmr/README.md b/packages/mcp-hmr/README.md new file mode 100644 index 0000000..df3d6c8 --- /dev/null +++ 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. diff --git a/packages/mcp-hmr/mcp_hmr.py b/packages/mcp-hmr/mcp_hmr.py new file mode 100644 index 0000000..685b13e --- /dev/null +++ b/packages/mcp-hmr/mcp_hmr.py @@ -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 + + 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 + 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 + + @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, 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() diff --git a/packages/mcp-hmr/pyproject.toml b/packages/mcp-hmr/pyproject.toml new file mode 100644 index 0000000..b6a993c --- /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 = { 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", +] +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" + 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"