Skip to content

Commit 72c0fb0

Browse files
Merge branch 'readme' into mcp-anyio
2 parents c03e96c + f2ae6f2 commit 72c0fb0

11 files changed

Lines changed: 189 additions & 32 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,16 @@ jobs:
1212
check:
1313
strategy:
1414
matrix:
15-
os: [ubuntu, macos]
15+
os: [ubuntu, macos, windows]
16+
py: ["3.12", "3.13", "3.14"]
1617
fail-fast: false
1718
runs-on: ${{ matrix.os }}-latest
1819
steps:
1920
- uses: actions/checkout@v5
20-
- name: Set up Python
21+
- name: Set up Python ${{ matrix.py }}
2122
uses: astral-sh/setup-uv@v7
2223
with:
23-
python-version: 3.13
24+
python-version: ${{ matrix.py }}
2425
activate-environment: true
2526
- name: Install dependencies
2627
run: |
@@ -55,14 +56,14 @@ jobs:
5556
- name: Set up Python
5657
uses: astral-sh/setup-uv@v7
5758
with:
58-
python-version: 3.13
59+
python-version: 3.14
5960
- name: Remove admonitions from READMEs
6061
run: |
6162
find . -type f -name "README.md" -exec sed -i -E '/^> \[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$/d' {} +
6263
- name: Remove all noqa comments
6364
run: |
6465
uvx ruff check --select RUF100 --show-fixes --fix packages/${{ matrix.dir }}
65-
- name: Build packages
66+
- name: Build wheel for ${{ matrix.dir }}
6667
run: |
6768
uv build packages/${{ matrix.dir }} --wheel
6869
- name: Publish to PyPI

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,29 @@ If you have `uv` installed, you can try `hmr` directly with:
3636
uvx hmr path/to/your/entry-file.py
3737
```
3838

39+
## Ecosystem
40+
41+
HMR provides a rich ecosystem of tools for different Python development scenarios:
42+
43+
| Package | Use Case |
44+
| -------------------------------------------------- | ------------------------------------------------------------ |
45+
| [`hmr`][repo] | Reactive programming lib and HMR core implementation |
46+
| [`uvicorn-hmr`](./packages/uvicorn-hmr/) | HMR-enabled Uvicorn server for ASGI apps |
47+
| [`mcp-hmr`](./packages/mcp-hmr/) | HMR-enabled MCP / FastMCP servers |
48+
| [`hmr-daemon`](./packages/hmr-daemon/) | Background daemon that refreshes modules on changes |
49+
| [`fastapi-reloader`](./packages/fastapi-reloader/) | Browser auto-refresh middleware for automatic page reloading |
50+
3951
> [!TIP]
40-
> The hmr ecosystem is now production-ready. It has been carefully designed to handle many common edge cases and Pythonic *magic* patterns, including lazy imports, circular dependencies, dynamic imports, module-level `__getattr__`, decorators, and more. You can confidently use hmr in production environments if needed.
52+
> The hmr ecosystem is basically stable and production-ready for most use cases. It has been carefully designed to handle many common edge cases and Pythonic *magic* patterns, including lazy imports, dynamic imports, module-level `__getattr__`, decorators, and more. However, circular dependencies in some edge cases may still cause unexpected behavior. Use with caution if you have a lot of code in `__init__.py`.
4153
4254
https://github.com/user-attachments/assets/fb247649-193d-4eed-b778-05b02d47c3f6
4355

56+
## Other demos
57+
58+
- [`demo/`](./examples/demo/) - Basic script with hot module reloading
59+
- [`fastapi/`](./examples/fastapi/) - FastAPI server with hot reloading and browser refresh
60+
- [`flask/`](./examples/flask/) - Flask app with hot module reloading
61+
- [`mcp/`](./examples/mcp/) - MCP server with live code updates without connection drops
4462

4563
## Motivation
4664

examples/fastapi/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name = "fastapi-example"
33
version = "0"
44
requires-python = ">=3.12"
55
dependencies = [
6-
"fastapi~=0.119.0",
6+
"fastapi~=0.120.0",
77
"uvicorn-hmr[all]",
88
]
99

examples/mcp/README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# MCP Example
2+
3+
This example demonstrates how to use `mcp-hmr` with a FastMCP server.
4+
5+
First, install the required dependencies and activate the venv:
6+
7+
```sh
8+
uv sync
9+
source .venv/bin/activate # On Windows use `.venv\Scripts\activate`
10+
```
11+
12+
## Demo 1 - with `@modelcontextprotocol/inspector`
13+
14+
First, open the inspector with `npx` / `pnpx` / `bunx`, etc:
15+
16+
```sh
17+
npx @modelcontextprotocol/inspector --config mcp.json --server main
18+
```
19+
20+
Click the "Connect" button in the inspector UI. It will connect to the MCP server with `mcp-hmr main.py:app`.
21+
22+
<img width="3200" height="1116" alt="The MCP Inspector" src="https://github.com/user-attachments/assets/3e96039f-2720-4b14-be4d-2a54678d49dc" />
23+
24+
Now you can view the resources and tools as normal. Then change them in `main.py` and save the file WITHOUT clicking "Restart" in the inspector. Any call to get the resource / run the tool will reflect the updated code.
25+
26+
https://github.com/user-attachments/assets/fe8810a3-916d-49ab-91e5-5a595ae0b3f9
27+
28+
## Demo 2 - with FastMCP client
29+
30+
Run the client to start and use the server:
31+
32+
```sh
33+
python client.py
34+
```
35+
36+
It uses the MCP server defined in `main.py`. Now open your favorite editor and modify `main.py` to see the printing update in real-time!
37+
38+
## What to Observe
39+
40+
- Try changing the `echo` tool's return value or the `greet` resource's content.
41+
- You will see the client output update to reflect your changes without restarting the connection.
42+
43+
This demo shows how to use `mcp-hmr` to enable seamless hot reloading for MCP servers, maintaining the connection between client and server while updating the code on-the-fly.

examples/mcp/client.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from contextlib import suppress
2+
from pathlib import Path
3+
4+
from anyio import run, sleep
5+
from fastmcp import Client
6+
7+
config = {
8+
"mcpServers": {
9+
"": {
10+
"transport": "stdio",
11+
"command": "mcp-hmr",
12+
"args": [f"{Path(__file__).parent / 'main.py'}:app"],
13+
},
14+
}
15+
}
16+
17+
18+
with suppress(KeyboardInterrupt):
19+
20+
@run
21+
async def _():
22+
async with Client(config) as client:
23+
while True:
24+
print("\033c", end="") # clear the console
25+
26+
print("from tool echo:", (await client.call_tool("echo", {"message": "Hello from HMR!"})).content[0].text) # type: ignore
27+
print("from resource example://greet:", (await client.read_resource("example://greet"))[0].text) # type: ignore
28+
29+
await sleep(1)

examples/mcp/main.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from fastmcp import FastMCP
2+
3+
# from mcp.server.fastmcp import FastMCP # FastMCP v1 is also supported!
4+
5+
6+
app = FastMCP()
7+
8+
9+
@app.tool()
10+
def echo(message: str):
11+
return message
12+
13+
14+
@app.resource("example://greet")
15+
def greet():
16+
return "hello world"
17+
18+
19+
# `mcp-hmr main:app` is equivalent to:
20+
21+
if __name__ == "__main__":
22+
app.run("stdio")

examples/mcp/mcp.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"mcpServers": {
3+
"main": {
4+
"command": "mcp-hmr",
5+
"args": ["main.py:app"]
6+
}
7+
}
8+
}

examples/mcp/pyproject.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[project]
2+
name = "mcp-example"
3+
version = "0"
4+
requires-python = ">=3.12"
5+
dependencies = [
6+
"fastmcp~=2.12.5",
7+
"mcp-hmr",
8+
]
9+
10+
[tool.uv]
11+
package = false
12+
13+
[tool.uv.sources]
14+
mcp-hmr = { workspace = true }

packages/mcp-hmr/README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,22 @@
55

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

8-
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.
8+
It acts as **a drop-in replacement for `mcp run path:app` or `fastmcp run path:app`.** Both [FastMCP v2](https://github.com/jlowin/fastmcp) and the [official python SDK](https://github.com/modelcontextprotocol/python-sdk) are supported.
99

1010
## Usage
1111

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

1414
```sh
15-
mcp-hmr path.to.main:app
15+
mcp-hmr ./path/to/main.py:app
1616
```
1717

18-
Which will be equivalent to the following code but with [HMR](https://github.com/promplate/hmr) enabled:
18+
Which will be equivalent to `fastmcp run ./path/to/main.py:app` but with [HMR](https://github.com/promplate/hmr) enabled.
1919

20-
```py
21-
from path.to.main import app
20+
Or using module import format:
2221

23-
app.run("stdio")
22+
```sh
23+
mcp-hmr main:app
2424
```
2525

2626
Now, whenever you save changes to your source code, the server will automatically reload without dropping the connection to the client.

packages/mcp-hmr/mcp_hmr.py

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
import sys
2+
from importlib import import_module
3+
from importlib.machinery import ModuleSpec
4+
from importlib.util import find_spec, module_from_spec
5+
from pathlib import Path
26

3-
__version__ = "0.0.1"
7+
__version__ = "0.0.2.3"
48

59

610
async def run_with_hmr(target: str, log_level: str | None = None):
7-
module, attr = target.split(":")
11+
module, attr = target.rsplit(":", 1)
812

9-
from contextlib import contextmanager
10-
from importlib import import_module
13+
from contextlib import contextmanager, suppress
1114

1215
import mcp.server
1316
from anyio import Event, Lock, create_task_group
1417
from fastmcp import FastMCP
1518
from fastmcp.server.proxy import ProxyClient
1619
from reactivity import async_effect, derived
17-
from reactivity.hmr.core import HMR_CONTEXT, AsyncReloader
20+
from reactivity.hmr.core import HMR_CONTEXT, AsyncReloader, _loader
1821
from reactivity.hmr.hooks import call_post_reload_hooks, call_pre_reload_hooks
1922

2023
base_app = FastMCP(name="proxy", include_fastmcp_meta=False)
@@ -28,9 +31,10 @@ def mount(app: FastMCP | mcp.server.FastMCP):
2831
for mounted_server in list(base_app._mounted_servers): # noqa: SLF001
2932
if mounted_server.server is proxy:
3033
base_app._mounted_servers.remove(mounted_server) # noqa: SLF001
31-
base_app._tool_manager._mounted_servers.remove(mounted_server) # noqa: SLF001
32-
base_app._resource_manager._mounted_servers.remove(mounted_server) # noqa: SLF001
33-
base_app._prompt_manager._mounted_servers.remove(mounted_server) # noqa: SLF001
34+
with suppress(AttributeError):
35+
base_app._tool_manager._mounted_servers.remove(mounted_server) # noqa: SLF001
36+
base_app._resource_manager._mounted_servers.remove(mounted_server) # noqa: SLF001
37+
base_app._prompt_manager._mounted_servers.remove(mounted_server) # noqa: SLF001
3438
break
3539

3640
lock = Lock()
@@ -41,9 +45,17 @@ async def using(app: FastMCP | mcp.server.FastMCP, stop_event: Event, finish_eve
4145
await stop_event.wait()
4246
finish_event.set()
4347

44-
@derived(context=HMR_CONTEXT)
45-
def get_app():
46-
return getattr(import_module(module), attr)
48+
if Path(module).is_file(): # module:attr
49+
50+
@derived(context=HMR_CONTEXT)
51+
def get_app():
52+
return getattr(module_from_spec(ModuleSpec("server_module", _loader, origin=module)), attr)
53+
54+
else: # path:attr
55+
56+
@derived(context=HMR_CONTEXT)
57+
def get_app():
58+
return getattr(import_module(module), attr)
4759

4860
stop_event: Event | None = None
4961
finish_event: Event = ... # type: ignore
@@ -87,8 +99,10 @@ async def __aexit__(self, *_):
8799
def cli(argv: list[str] = sys.argv[1:]):
88100
from argparse import SUPPRESS, ArgumentParser
89101

90-
parser = ArgumentParser(prog="mcp-hmr", description="Hot Reloading for MCP Servers • Automatically reload on code changes")
91-
parser.add_argument("target", help="The import path of the FastMCP instance, e.g. `main:app` means `from main import app`", metavar="module:attr")
102+
parser = ArgumentParser("mcp-hmr", description="Hot Reloading for MCP Servers • Automatically reload on code changes")
103+
if sys.version_info >= (3, 14):
104+
parser.suggest_on_error = True
105+
parser.add_argument("target", help="The import path of the FastMCP instance. Supports module:attr and path:attr")
92106
parser.add_argument("-l", "--log-level", choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], type=str.upper, default=None)
93107
parser.add_argument("--version", action="version", version=f"mcp-hmr {__version__}", help=SUPPRESS)
94108

@@ -100,17 +114,27 @@ def cli(argv: list[str] = sys.argv[1:]):
100114

101115
target: str = args.target
102116

103-
if target.count(":") != 1 or target.startswith(":") or target.endswith(":"):
104-
parser.exit(1, f"The target argument must be in the format 'module:attr', e.g. 'main:app'. Got: '{target}'")
117+
if ":" not in target[1:-1]:
118+
parser.exit(1, f"The target argument must be in the format 'module:attr' (e.g. 'main:app') or 'path:attr' (e.g. './path/to/main.py:app'). Got: '{target}'")
105119

106120
from contextlib import suppress
107-
from pathlib import Path
108121

109122
from anyio import run
110123

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

127+
if (file := Path(module_or_path := target[: target.rindex(":")])).is_file():
128+
sys.path.insert(0, str(file.parent))
129+
else:
130+
if "." in module_or_path: # find_spec may cause implicit imports of parent packages
131+
from reactivity.hmr.core import patch_meta_path
132+
133+
patch_meta_path()
134+
135+
if find_spec(module_or_path) is None:
136+
parser.exit(1, f"The target '{module_or_path}' not found. Please provide a valid module name or a file path.")
137+
114138
with suppress(KeyboardInterrupt):
115139
run(run_with_hmr, target, args.log_level, backend="trio")
116140

0 commit comments

Comments
 (0)