Skip to content

Implement mcp-hmr library and demo#23

Closed
CNSeniorious000 wants to merge 4 commits intoreadmefrom
cursor/implement-mcp-hmr-library-and-demo-2f44
Closed

Implement mcp-hmr library and demo#23
CNSeniorious000 wants to merge 4 commits intoreadmefrom
cursor/implement-mcp-hmr-library-and-demo-2f44

Conversation

@CNSeniorious000
Copy link
Copy Markdown
Member

@CNSeniorious000 CNSeniorious000 commented Sep 20, 2025

Implement mcp-hmr library and a demo to provide hot module reloading for MCP servers, enhancing developer experience.

This library enables developers to make changes to their MCP server code and see them applied instantly without restarting the entire server process, mirroring the functionality of uvicorn-hmr for ASGI servers.


Open in Cursor Open in Web

Summary by CodeRabbit

  • New Features

    • Introduced MCP-HMR for hot reloading MCP servers with dependency-aware reloads.
    • Added CLI with include/exclude watch options, stdio/SSE transport, and terminal clearing.
    • Provided a demo MCP server (Echo, Calculate, Time) and a sample client for end-to-end testing.
    • Included an installation script for quick setup.
  • Documentation

    • Added detailed READMEs for MCP-HMR and the demo, with quick start, usage, workflow, and comparisons.
    • Updated root README with package overview and MCP-HMR getting started guide.
  • Chores

    • Added package and workspace configurations, scripts, and dev dependencies.

Co-authored-by: me <me@promplate.dev>
@cursor
Copy link
Copy Markdown

cursor Bot commented Sep 20, 2025

Cursor Agent can help with this pull request. Just @cursor in comments and I'll start working on changes in this branch.
Learn more about Cursor Agents

@CNSeniorious000
Copy link
Copy Markdown
Member Author

@cursor uv sync --all-packages
× Failed to build mcp-hmr @ │ file:///home/runner/work/hmr/hmr/packages/mcp-hmr
├─▶ Failed to parse entry: hmr
╰─▶ hmr references a workspace in tool.uv.sources (e.g., hmr = { workspace = true }), but is not a workspace member

@cursor
Copy link
Copy Markdown

cursor Bot commented Sep 20, 2025

I refactored dependency management, integrating mcp-hmr as a root workspace dependency and relaxing mcp version constraints across sub-projects for simpler resolution.

  • I added mcp-hmr to the root pyproject.toml dependencies and [tool.pdm.dev-dependencies.dev] as a workspace member.
  • I removed mcp>=1.0.0 from packages/mcp-hmr/pyproject.toml and its [tool.uv.sources] hmr entry.
  • I relaxed the mcp version constraint in examples/mcp-demo/pyproject.toml from mcp>=1.0.0 to mcp.

Open in Cursor Open in Web

Learn more about Cursor Agents

Co-authored-by: me <me@promplate.dev>
Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

New security issues found

Comment thread examples/mcp-demo/client.py Outdated
@CNSeniorious000
Copy link
Copy Markdown
Member Author

CNSeniorious000 commented Sep 20, 2025

@cursor Ruff is complaining a lot ...

Details

Run ruff format --check --diff
--- examples/mcp-demo/client.py
+++ examples/mcp-demo/client.py
@@ -2,6 +2,7 @@
 """
 Simple MCP client for testing the demo server.
 """
+
 import asyncio
 import json
 import subprocess
@@ -11,74 +12,60 @@
 
 class MCPClient:
     """A simple MCP client implementation."""
-    
+
     def __init__(self):
         self.process: Optional[subprocess.Popen] = None
         self.request_id = 0
-    
+
     def get_next_id(self) -> int:
         """Get the next request ID."""
         self.request_id += 1
         return self.request_id
-    
+
     async def start_server(self, command: list[str]):
         """Start the MCP server process."""
         print(f"🚀 Starting server: {' '.join(command)}")
-        self.process = subprocess.Popen(
-            command,
-            stdin=subprocess.PIPE,
-            stdout=subprocess.PIPE,
-            stderr=subprocess.PIPE,
-            text=True,
-            bufsize=0
-        )
-        
+        self.process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=0)
+
-            
+
             try:
                 if asyncio.iscoroutinefunction(server_func):
                     # If it's an async function, run it in an event loop
@@ -190,15 +190,16 @@
 
     logger = getLogger("mcp.hmr")
     logger.setLevel("INFO")
-    
+
     # Set up console handler if not already present
     if not logger.handlers:
         import logging
+
         handler = logging.StreamHandler()
-        formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
+        formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
         handler.setFormatter(formatter)
         logger.addHandler(handler)
-    
+
     try:
         (reloader := Reloader()).keep_watching_until_interrupt()
     except KeyboardInterrupt:
@@ -216,4 +217,4 @@
 
 
 if __name__ == "__main__":
-    app()
\ No newline at end of file
+    app()

Error: Process completed with exit code 1.

@cursor
Copy link
Copy Markdown

cursor Bot commented Sep 20, 2025

I refactored the MCP demo client and server, primarily by simplifying dictionary initializations and updating type hints for improved readability and modern Python practices. Additionally, I cleaned up the mcp-hmr package by removing unused imports and adding noqa comments for linting.

  • In examples/mcp-demo/client.py, I streamlined dictionary creations in send_request and test_server by inlining them, reducing verbosity. I also updated type hints for self.process to subprocess.Popen | None and removed deprecated typing.Dict, typing.Optional imports.
  • For examples/mcp-demo/server.py, I simplified dictionary initializations within add_tool, add_resource, and handle_request methods for conciseness. I also updated type hints, removing typing.Dict and typing.List, and refined error message formatting in calculate_handler using f"{e!s}".
  • Within packages/mcp-hmr/mcp_hmr/__init__.py, I removed unused imports like json and signal to clean up the module. I also added noqa comments (# noqa: ARG001, # noqa: FBT002) to transport and clear parameters in main to suppress specific linting warnings.

Open in Cursor Open in Web

Learn more about Cursor Agents

This commit refactors the MCP client and server examples for better clarity and efficiency. It includes updates to the client's request handling, server's tool registration, and general code cleanup.

Co-authored-by: me <me@promplate.dev>
@CNSeniorious000
Copy link
Copy Markdown
Member Author

@cursor Now pyright is complaining:

/home/runner/work/hmr/hmr/examples/mcp-demo/client.py:48:28 - error: "write" is not a known attribute of "None" (reportOptionalMemberAccess)
Error: "write" is not a known attribute of "None" (reportOptionalMemberAccess)
/home/runner/work/hmr/hmr/examples/mcp-demo/client.py:49:28 - error: "flush" is not a known attribute of "None" (reportOptionalMemberAccess)
Error: "flush" is not a known attribute of "None" (reportOptionalMemberAccess)
/home/runner/work/hmr/hmr/examples/mcp-demo/client.py:52:45 - error: "readline" is not a known attribute of "None" (reportOptionalMemberAccess)
Error: "readline" is not a known attribute of "None" (reportOptionalMemberAccess)
Error: 3 errors
3 errors, 0 warnings, 0 notes

@cursor
Copy link
Copy Markdown

cursor Bot commented Sep 20, 2025

I enhanced the project's development tooling by integrating PyRight for static type checking. This involved adding PyRight as a development dependency and introducing type assertions in the MCPClient to improve type inference and ensure robustness.

  • I added pyright>=1.1.405 to a new [dependency-groups.dev] section in pyproject.toml, formally integrating PyRight for static type analysis.
  • In examples/mcp-demo/client.py, I introduced type assertions (assert self.process is not None, assert self.process.stdin is not None, etc.) within the _send_request method. This helps PyRight correctly infer types for self.process and its I/O streams, improving code safety.
  • I refactored the stop_server method in examples/mcp-demo/client.py to use a local process variable, improving readability and slightly optimizing repeated attribute access.

Open in Cursor Open in Web

Learn more about Cursor Agents

Co-authored-by: me <me@promplate.dev>
@CNSeniorious000 CNSeniorious000 marked this pull request as ready for review September 20, 2025 20:03
Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey there - I've reviewed your changes - here's some feedback:

Blocking issues:

  • Detected subprocess function 'Popen' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'. (link)

General comments:

  • The CLI entrypoint in mcp_hmr/init.py is quite large and handles module resolution, file watching, and server lifecycle; consider refactoring it into smaller modules or helper classes (e.g., separate file‐watcher setup, server runner, and logging config) to improve readability and testability.
  • The manual slug parsing and module resolution logic walks sys.path and filesystem directly; you could simplify and harden this by using importlib’s built-in finders and spec utilities rather than custom path checks.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The CLI entrypoint in mcp_hmr/__init__.py is quite large and handles module resolution, file watching, and server lifecycle; consider refactoring it into smaller modules or helper classes (e.g., separate file‐watcher setup, server runner, and logging config) to improve readability and testability.
- The manual slug parsing and module resolution logic walks sys.path and filesystem directly; you could simplify and harden this by using importlib’s built-in finders and spec utilities rather than custom path checks.

## Individual Comments

### Comment 1
<location> `packages/mcp-hmr/mcp_hmr/__init__.py:53-57` </location>
<code_context>
+
+    file = file.resolve()
+
+    if module in sys.modules:
+        return secho(
+            f"It seems you've already imported `{module}` as a normal module. You should call `reactivity.hmr.core.patch_meta_path()` before it.",
+            fg="red",
+        )
+
+    from atexit import register
</code_context>

<issue_to_address>
**issue (bug_risk):** Early return after secho may not exit the process as intended.

If you intend to stop execution when the module is already imported, use exit(1) or raise an exception instead of just returning.
</issue_to_address>

### Comment 2
<location> `packages/mcp-hmr/mcp_hmr/__init__.py:138` </location>
<code_context>
+            if "." in module:
+                __import__(module.rsplit(".", 1)[0])  # ensure parent modules are imported
+
+            if __version__ >= "0.6.4":
+                from reactivity.hmr.core import _loader as loader
+            else:
</code_context>

<issue_to_address>
**issue (bug_risk):** String comparison for version may not work as expected for semantic versions.

Lexicographical string comparison can misorder semantic versions. Use packaging.version.parse or an equivalent method for accurate version checks.
</issue_to_address>

### Comment 3
<location> `packages/mcp-hmr/mcp_hmr/__init__.py:189-198` </location>
<code_context>
+            ):
+                return super().start_watching()
+
+    logger = getLogger("mcp.hmr")
+    logger.setLevel("INFO")
+
+    # Set up console handler if not already present
+    if not logger.handlers:
+        import logging
+
+        handler = logging.StreamHandler()
+        formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
+        handler.setFormatter(formatter)
+        logger.addHandler(handler)
+
+    try:
</code_context>

<issue_to_address>
**nitpick:** Logger level is set to "INFO" as a string instead of logging.INFO.

Using a string for the logger level may cause unexpected behavior. Please use logging.INFO instead.
</issue_to_address>

### Comment 4
<location> `examples/mcp-demo/client.py:51-54` </location>
<code_context>
+
+        # Send request
+        request_json = json.dumps(request) + "\n"
+        self.process.stdin.write(request_json)
+        self.process.stdin.flush()
+
+        # Read response
</code_context>

<issue_to_address>
**suggestion:** No error handling for broken pipe when writing to stdin.

Add a try-except block to handle BrokenPipeError when writing to stdin, ensuring the client does not crash if the server process exits unexpectedly.

```suggestion
        # Send request
        request_json = json.dumps(request) + "\n"
        try:
            self.process.stdin.write(request_json)
            self.process.stdin.flush()
        except BrokenPipeError:
            raise RuntimeError("Failed to write to server process stdin: Broken pipe. The server process may have exited unexpectedly.")
```
</issue_to_address>

### Comment 5
<location> `packages/mcp-hmr/mcp_hmr/__init__.py:13` </location>
<code_context>
+
+
+@app.command(no_args_is_help=True)
+def main(
+    slug: Annotated[str, Argument(help="MCP server module and function (e.g., 'server:main' or 'server:app')")] = "server:main",
+    reload_include: list[str] = [str(Path.cwd())],  # noqa: B006, B008
</code_context>

<issue_to_address>
**issue (complexity):** Consider refactoring the code to separate CLI parsing, server lifecycle management, reloader logic, and logging into distinct modules for improved clarity and maintainability.

Here are a few focused refactorings that will flatten the nesting and split responsibilities without changing any behavior:

1. Extract CLI parsing into `hmr/cli.py`

```python
# hmr/cli.py
from typer import Typer, Argument, Option, secho
from .runner import run_hmr

app = Typer(help="Hot Module Replacement for MCP", add_completion=False)

@app.command(no_args_is_help=True)
def main(
    slug: str = Argument("server:main", help="module:function"),
    reload_include: list[str] = Option([str(Path.cwd())], "--reload-include"),
    reload_exclude: list[str] = Option([".venv", "__pycache__", "*.pyc"], "--reload-exclude"),
    transport: str = Option("stdio", "--transport"),
    clear: bool = Option(False, "--clear"),
):
    if ":" not in slug:
        secho(f"Invalid slug `{slug}`, expected module:function", fg="red")
        raise SystemExit(1)
    run_hmr(slug, reload_include, reload_exclude, transport, clear)
```

2. Move server‐lifecycle into a `ServerManager` in `hmr/runner.py`

```python
# hmr/runner.py
import asyncio
import threading
from typing import Callable

class ServerManager:
    def __init__(self, clear: bool):
        self._clear = clear
        self._task = None
        self._event = threading.Event()

    def start(self, server_func: Callable):
        def _run():
            try:
                if asyncio.iscoroutinefunction(server_func):
                    loop = asyncio.new_event_loop()
                    loop.run_until_complete(server_func())
                    loop.close()
                else:
                    server_func()
            finally:
                self._event.set()

        thread = threading.Thread(target=_run, daemon=True)
        thread.start()

    def stop(self):
        if self._task and not self._task.done():
            self._task.cancel()
        self._event.set()

def run_hmr(slug, inc, exc, transport, clear):
    # module resolution + import machinery as before...
    from .reloader import HmrReloader
    manager = ServerManager(clear)
    reloader = HmrReloader(module, file_path, attr, inc, exc, manager)
    reloader.keep_watching_until_interrupt()
    manager.stop()
```

3. Isolate the reloader in `hmr/reloader.py`

```python
# hmr/reloader.py
from watchfiles import Change
from reactivity.hmr.core import SyncReloader, ReactiveModule
from .runner import ServerManager

class HmrReloader(SyncReloader):
    def __init__(self, module, filepath, attr, include, exclude, server_mgr: ServerManager):
        super().__init__(str(filepath), include, exclude)
        self.module = module
        self.filepath = filepath
        self.attr = attr
        self.server_mgr = server_mgr

    def run_entry_file(self):
        self.server_mgr.stop()
        mod = self._load_module()     # factor out your cached_property logic
        srv = getattr(mod, self.attr)
        self.server_mgr.start(srv)

    def on_events(self, events: set[Change]):
        # same logic as before, then call super()
        return super().on_events(events)
```

4. Delegate logging setup to `hmr/logging.py` or similar.  

By splitting into `cli.py`, `runner.py`, `reloader.py` (and optional `logging.py`), you:

- eliminate deep nested closures,
- remove most `nonlocal` usage,
- keep each file under ~100 lines,
- and make each unit test‐friendly and self‐contained.
</issue_to_address>

### Comment 6
<location> `examples/mcp-demo/client.py:27` </location>
<code_context>
        self.process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=0)  # noqa: ASYNC220
</code_context>

<issue_to_address>
**security (python.lang.security.audit.dangerous-subprocess-use-audit):** Detected subprocess function 'Popen' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'.

*Source: opengrep*
</issue_to_address>

### Comment 7
<location> `examples/mcp-demo/client.py:62-63` </location>
<code_context>
    async def send_request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
        """Send a request to the MCP server."""
        if not self.process:
            raise RuntimeError("Server not started")

        # Type assertion to help PyRight understand that self.process is not None
        assert self.process is not None
        assert self.process.stdin is not None
        assert self.process.stdout is not None

        request = {"jsonrpc": "2.0", "id": self.get_next_id(), "method": method}

        if params:
            request["params"] = params

        # Send request
        request_json = json.dumps(request) + "\n"
        self.process.stdin.write(request_json)
        self.process.stdin.flush()

        # Read response
        response_line = self.process.stdout.readline().strip()
        if not response_line:
            raise RuntimeError("No response from server")

        try:
            response = json.loads(response_line)
            return response
        except json.JSONDecodeError as e:
            raise RuntimeError(f"Invalid JSON response: {e}") from e

</code_context>

<issue_to_address>
**suggestion (code-quality):** Inline variable that is immediately returned ([`inline-immediately-returned-variable`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/inline-immediately-returned-variable/))

```suggestion
            return json.loads(response_line)
```
</issue_to_address>

### Comment 8
<location> `examples/mcp-demo/server.py:51` </location>
<code_context>
    async def handle_request(self, request: dict[str, Any]) -> dict[str, Any]:
        """Handle an incoming MCP request."""
        method = request.get("method")
        params = request.get("params", {})
        request_id = request.get("id")

        try:
            if method == "initialize":
                return {
                    "jsonrpc": "2.0",
                    "id": request_id,
                    "result": {
                        "protocolVersion": "2024-11-05",
                        "capabilities": {
                            "tools": {"listChanged": True},
                            "resources": {"subscribe": True, "listChanged": True},
                        },
                        "serverInfo": {"name": self.name, "version": self.version},
                    },
                }

            elif method == "tools/list":
                tools_list = []
                for _tool_name, tool_info in self.tools.items():
                    tools_list.append({"name": tool_info["name"], "description": tool_info["description"], "inputSchema": tool_info["inputSchema"]})

                return {"jsonrpc": "2.0", "id": request_id, "result": {"tools": tools_list}}

            elif method == "tools/call":
                tool_name = params.get("name")
                arguments = params.get("arguments", {})

                if tool_name not in self.tools:
                    raise ValueError(f"Unknown tool: {tool_name}")

                handler = self.tools[tool_name]["handler"]
                result = await handler(arguments)

                return {"jsonrpc": "2.0", "id": request_id, "result": {"content": result}}

            elif method == "resources/list":
                resources_list = []
                for _uri, resource_info in self.resources.items():
                    resources_list.append({"uri": resource_info["uri"], "name": resource_info["name"], "description": resource_info["description"]})

                return {"jsonrpc": "2.0", "id": request_id, "result": {"resources": resources_list}}

            else:
                raise ValueError(f"Unknown method: {method}")

        except Exception as e:
            return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32000, "message": str(e)}}

</code_context>

<issue_to_address>
**issue (code-quality):** Convert for loop into list comprehension [×2] ([`list-comprehension`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/list-comprehension/))
</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 on lines +53 to +57
if module in sys.modules:
return secho(
f"It seems you've already imported `{module}` as a normal module. You should call `reactivity.hmr.core.patch_meta_path()` before it.",
fg="red",
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): Early return after secho may not exit the process as intended.

If you intend to stop execution when the module is already imported, use exit(1) or raise an exception instead of just returning.

if "." in module:
__import__(module.rsplit(".", 1)[0]) # ensure parent modules are imported

if __version__ >= "0.6.4":
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): String comparison for version may not work as expected for semantic versions.

Lexicographical string comparison can misorder semantic versions. Use packaging.version.parse or an equivalent method for accurate version checks.

Comment on lines +189 to +198
logger = getLogger("mcp.hmr")
logger.setLevel("INFO")

# Set up console handler if not already present
if not logger.handlers:
import logging

handler = logging.StreamHandler()
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
handler.setFormatter(formatter)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nitpick: Logger level is set to "INFO" as a string instead of logging.INFO.

Using a string for the logger level may cause unexpected behavior. Please use logging.INFO instead.

Comment on lines +51 to +54
# Send request
request_json = json.dumps(request) + "\n"
self.process.stdin.write(request_json)
self.process.stdin.flush()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: No error handling for broken pipe when writing to stdin.

Add a try-except block to handle BrokenPipeError when writing to stdin, ensuring the client does not crash if the server process exits unexpectedly.

Suggested change
# Send request
request_json = json.dumps(request) + "\n"
self.process.stdin.write(request_json)
self.process.stdin.flush()
# Send request
request_json = json.dumps(request) + "\n"
try:
self.process.stdin.write(request_json)
self.process.stdin.flush()
except BrokenPipeError:
raise RuntimeError("Failed to write to server process stdin: Broken pipe. The server process may have exited unexpectedly.")

async def start_server(self, command: list[str]):
"""Start the MCP server process."""
print(f"🚀 Starting server: {' '.join(command)}")
self.process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=0) # noqa: ASYNC220
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security (python.lang.security.audit.dangerous-subprocess-use-audit): Detected subprocess function 'Popen' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'.

Source: opengrep

Comment on lines +62 to +63
response = json.loads(response_line)
return response
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (code-quality): Inline variable that is immediately returned (inline-immediately-returned-variable)

Suggested change
response = json.loads(response_line)
return response
return json.loads(response_line)

}

elif method == "tools/list":
tools_list = []
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (code-quality): Convert for loop into list comprehension [×2] (list-comprehension)

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Sep 20, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

📝 Walkthrough

Walkthrough

Introduces a new MCP-HMR package with a Typer-based CLI for hot-reloading MCP servers, adds an MCP demo server and client, updates workspace and root documentation, provides packaging and project configs, and includes an installation script. Implements file watching, dependency-aware reloads, and stdio transport for MCP JSON-RPC.

Changes

Cohort / File(s) Summary
MCP-HMR core package
packages/mcp-hmr/mcp_hmr/__init__.py, packages/mcp-hmr/pyproject.toml, packages/mcp-hmr/README.md
New package providing CLI (Typer) for HMR of MCP servers, reloader integration, transport selection, packaging metadata, and usage docs.
MCP demo (server, client, project config, docs)
examples/mcp-demo/server.py, examples/mcp-demo/client.py, examples/mcp-demo/pyproject.toml, examples/mcp-demo/README.md
Adds demo MCP server (tools: echo, calculate, time), async stdio loop, JSON-RPC handling, test client, project config, and walkthrough docs.
Root docs
README.md, README-mcp-hmr.md, IMPLEMENTATION-SUMMARY.md
Updates workspace README to include MCP-HMR, adds dedicated MCP-HMR guides and implementation summary.
Installer
install-mcp-hmr.sh
Adds script to install MCP-HMR from the workspace with fallbacks (pip, uv, pipx) and post-install hints.
Workspace config
pyproject.toml
Registers MCP-HMR in workspace, adds dependency entry and dev group (pyright).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

Hot code hums, the watcher sings,
Modules swap on reloaded wings.
Tools echo, count, and tell the time—
Server stays, threads keep their rhyme.
A tap of CLI, code comes alive—
MCP and HMR, swift to revive. ⏱️🔥

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 48.28% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "Implement mcp-hmr library and demo" succinctly and accurately summarizes the primary change (adding the mcp-hmr package and accompanying demo), is concise and specific, and directly reflects the changeset so a teammate scanning history will understand the main purpose.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch cursor/implement-mcp-hmr-library-and-demo-2f44

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Please see the documentation for more information.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (14)
README.md (1)

100-100: Minor typographic correction needed.

The apostrophe in "SolidJS's" should use a proper Unicode apostrophe for consistency.

-About fine-grained reactivity, I recommend reading [SolidJS's excellent explanation](https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity).
+About fine-grained reactivity, I recommend reading [SolidJS's excellent explanation](https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity).
packages/mcp-hmr/pyproject.toml (2)

28-30: Update placeholder URLs to actual repository.

The GitHub URLs appear to be placeholders and should point to the actual repository location.

-Homepage = "https://github.com/mcp-hmr/mcp-hmr"
-Repository = "https://github.com/mcp-hmr/mcp-hmr"
+Homepage = "https://github.com/promplate/hmr"
+Repository = "https://github.com/promplate/hmr"

8-8: Update placeholder email address.

The email address appears to be a placeholder and should be updated to a real contact.

-authors = [{ name = "MCP HMR", email = "dev@mcp-hmr.dev" }]
+authors = [{ name = "MCP HMR Contributors", email = "hmr@promplate.dev" }]
IMPLEMENTATION-SUMMARY.md (1)

53-59: Add language specification for code block.

The fenced code block should specify the language for better syntax highlighting.

-```
+```text
 mcp-hmr CLI
     ↓
 File Watcher (watchfiles) → HMR Engine (reactivity.hmr) → MCP Server Function
packages/mcp-hmr/README.md (2)

135-140: Inconsistent note on include/exclude “patterns” vs defaults

README says “patterns will be treated as literal paths,” yet defaults include *.pyc, which is a glob. Align the wording (and behavior) to explicitly support globs.


149-152: “Preserves connection state” claim likely too strong

Given the server task is cancelled/restarted on reload, clients may see a brief disconnect depending on transport. Rephrase to “attempts to preserve” and document caveats.

examples/mcp-demo/client.py (3)

90-99: Avoid deprecated loop API; prefer asyncio.to_thread

Use asyncio.to_thread in both client and server to avoid deprecated get_event_loop().run_in_executor(...).

Apply:

-        print("\n1. Initializing server...")
-        response = await client.send_request("initialize", {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "test-client", "version": "0.1.0"}})
+        print("\n1. Initializing server...")
+        response = await client.send_request(
+            "initialize",
+            {
+                "protocolVersion": "2024-11-05",
+                "capabilities": {},
+                "clientInfo": {"name": "test-client", "version": "0.1.0"},
+            },
+        )

And in stop_server:

-                await asyncio.wait_for(asyncio.get_event_loop().run_in_executor(None, process.wait), timeout=5.0)
+                await asyncio.wait_for(asyncio.to_thread(process.wait), timeout=5.0)

36-45: Optional member access: narrow once, use a local

Your asserts help Pyright, but a local alias also simplifies and silences optional‑access warnings.

Apply:

-        assert self.process is not None
-        assert self.process.stdin is not None
-        assert self.process.stdout is not None
+        assert self.process is not None
+        proc = self.process
+        assert proc.stdin is not None and proc.stdout is not None

And below:

-        self.process.stdin.write(request_json)
-        self.process.stdin.flush()
+        proc.stdin.write(request_json)
+        proc.stdin.flush()
-        response_line = self.process.stdout.readline().strip()
+        response_line = proc.stdout.readline().strip()

57-63: Avoid indefinite block on readline

Consider a timeout to prevent hangs if the server stops responding.

Apply:

-        response_line = self.process.stdout.readline().strip()
+        response_line = await asyncio.wait_for(
+            asyncio.to_thread(self.process.stdout.readline),  # type: ignore[union-attr]
+            timeout=10.0,
+        )
+        response_line = response_line.strip()
examples/mcp-demo/server.py (1)

90-101: Prefer asyncio.to_thread over get_event_loop().run_in_executor

This avoids deprecated APIs and is clearer.

Apply:

-                line = await asyncio.get_event_loop().run_in_executor(None, sys.stdin.readline)
+                line = await asyncio.to_thread(sys.stdin.readline)
packages/mcp-hmr/mcp_hmr/__init__.py (4)

118-121: Stop thread cleanly on reload to avoid leaks

You set finish, but never join the thread. Join with a short timeout to reduce thread buildup across reloads.

Apply:

-        server_thread = Thread(target=run_server, daemon=True)
+        server_thread = Thread(target=run_server, daemon=True)
         server_thread.start()
@@
-        def stop_server():
+        def stop_server():
             nonlocal server_task
             if server_task and not server_task.done():
                 server_task.cancel()
             finish.set()
+            server_thread.join(timeout=2.0)

27-31: Prefer sys.exit or Typer’s Exit over built‑in exit

exit() depends on site; use sys.exit(1) or raise typer.Exit(1) for CLIs.

Apply:

-        exit(1)
+        sys.exit(1)

5-5: typing.override availability

typing.override requires Python 3.12+. If you support 3.11, use from typing_extensions import override.

Apply:

-from typing import TYPE_CHECKING, Annotated, override
+from typing import TYPE_CHECKING, Annotated
+try:
+    from typing import override  # py312+
+except ImportError:  # py311
+    from typing_extensions import override  # type: ignore

85-87: Remove unused variable

server_process is defined but never used.

-        server_process = None
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 4d2c0ef and 2f85815.

📒 Files selected for processing (12)
  • IMPLEMENTATION-SUMMARY.md (1 hunks)
  • README-mcp-hmr.md (1 hunks)
  • README.md (1 hunks)
  • examples/mcp-demo/README.md (1 hunks)
  • examples/mcp-demo/client.py (1 hunks)
  • examples/mcp-demo/pyproject.toml (1 hunks)
  • examples/mcp-demo/server.py (1 hunks)
  • install-mcp-hmr.sh (1 hunks)
  • packages/mcp-hmr/README.md (1 hunks)
  • packages/mcp-hmr/mcp_hmr/__init__.py (1 hunks)
  • packages/mcp-hmr/pyproject.toml (1 hunks)
  • pyproject.toml (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
examples/mcp-demo/server.py (1)
packages/mcp-hmr/mcp_hmr/__init__.py (1)
  • main (13-206)
packages/mcp-hmr/mcp_hmr/__init__.py (2)
examples/mcp-demo/server.py (1)
  • main (201-203)
examples/mcp-demo/client.py (2)
  • stop_server (67-76)
  • start_server (24-34)
🪛 markdownlint-cli2 (0.18.1)
IMPLEMENTATION-SUMMARY.md

53-53: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Sourcery review
  • GitHub Check: Link Check
🔇 Additional comments (14)
pyproject.toml (3)

21-21: LGTM on workspace source mapping.

The workspace source configuration correctly maps mcp-hmr as a workspace dependency, consistent with the other packages.


56-59: LGTM on development dependencies.

The pyright>=1.1.405 requirement is current and properly structured within the dependency groups.


9-9: Workspace config OK — mcp-hmr present & included.
packages/mcp-hmr contains pyproject.toml and the mcp_hmr module; root pyproject.toml lists [tool.uv.workspace] members = ["examples/","packages/"], so mcp-hmr is a workspace member and workspace resolution should succeed.

examples/mcp-demo/pyproject.toml (1)

1-15: LGTM on demo project configuration.

The project metadata is well-defined with appropriate dependencies for an MCP demo. The workspace reference to mcp-hmr aligns with the root configuration.

README-mcp-hmr.md (1)

1-192: LGTM on comprehensive MCP-HMR documentation.

The documentation is thorough and well-structured, covering features, installation, usage, architecture, and comparisons. The examples are clear and the workflow instructions are detailed.

install-mcp-hmr.sh (1)

1-55: LGTM on installation script with robust fallbacks.

The script handles multiple installation scenarios gracefully with proper error checking and fallback strategies for different package managers.

README.md (1)

57-96: LGTM on workspace documentation updates.

The additions clearly document the new MCP-HMR package and provide helpful getting started instructions that integrate well with the existing content.

packages/mcp-hmr/pyproject.toml (1)

1-34: Overall package configuration looks solid.

The dependencies, entry point, and build system configuration are appropriate for the MCP-HMR package.

IMPLEMENTATION-SUMMARY.md (1)

1-123: LGTM on comprehensive implementation summary.

The summary effectively documents all the key components, architecture, and success metrics of the MCP-HMR implementation.

examples/mcp-demo/README.md (1)

1-156: LGTM on detailed demo documentation.

The README provides excellent step-by-step instructions for using the demo and demonstrates hot reloading capabilities clearly with practical examples.

examples/mcp-demo/client.py (1)

24-31: Subprocess safety: low risk here, but keep inputs static

Command comes from code, not user input, so risk is low. Keep it a list (as you already do) and avoid shell=True. Consider draining stderr in a background task to prevent pipe back‑pressure if the server logs heavily.

packages/mcp-hmr/mcp_hmr/__init__.py (3)

201-206: Graceful shutdown path looks good

Reloader loop exits cleanly and ensures stop_server() is called.


1-19: CLI surface and defaults look solid

Good Typer usage and sensible defaults for include/exclude; nice help text.


64-67: Incorrect: 'hmr = { workspace = true }' claim is wrong — mcp-hmr is covered by the workspace

No 'hmr = { workspace = true }' exists in the repo. Top-level pyproject declares [tool.uv.workspace] members = ["examples/","packages/"] (pyproject.toml:14–16), and examples/mcp-demo/pyproject.toml declares mcp-hmr = { workspace = true } (examples/mcp-demo/pyproject.toml:14–15). Ignore the original workspace-warning.

Likely an incorrect or invalid review comment.

Comment on lines +73 to +75
await asyncio.wait_for(asyncio.get_event_loop().run_in_executor(None, process.wait), timeout=5.0)
except TimeoutError:
process.kill()
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.

⚠️ Potential issue

Catch the right timeout exception

asyncio.wait_for raises asyncio.TimeoutError, not the built‑in TimeoutError.

Apply:

-            except TimeoutError:
+            except asyncio.TimeoutError:
                 process.kill()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await asyncio.wait_for(asyncio.get_event_loop().run_in_executor(None, process.wait), timeout=5.0)
except TimeoutError:
process.kill()
await asyncio.wait_for(asyncio.get_event_loop().run_in_executor(None, process.wait), timeout=5.0)
except asyncio.TimeoutError:
process.kill()
🤖 Prompt for AI Agents
In examples/mcp-demo/client.py around lines 73 to 75, the except block is
catching the built-in TimeoutError but asyncio.wait_for raises
asyncio.TimeoutError; update the except to catch asyncio.TimeoutError (or import
TimeoutError from asyncio and use that) so the timeout is handled correctly,
then keep the process.kill() logic as-is.

Comment on lines +17 to +19
transport: Annotated[str, Option("--transport", help="Transport method: 'stdio' or 'sse'")] = "stdio", # noqa: ARG001
clear: Annotated[bool, Option("--clear", help="Clear the terminal before restarting the server")] = False, # noqa: FBT002
):
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.

⚠️ Potential issue

--transport option is unused

The code ignores the selected transport; README advertises SSE. Either implement or drop the option for now.

Suggested minimal stopgap:

-    transport: Annotated[str, Option("--transport", help="Transport method: 'stdio' or 'sse'")] = "stdio",  # noqa: ARG001
+    transport: Annotated[str, Option("--transport", help="Transport method: 'stdio' (default). 'sse' not yet implemented.")]= "stdio",

And warn on non‑stdio:

-                server_func = getattr(self.entry_module, attr)
+                server_func = getattr(self.entry_module, attr)
+                if transport != "stdio":
+                    logger.warning("Transport '%s' is not implemented; falling back to 'stdio'.", transport)

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/mcp-hmr/mcp_hmr/__init__.py around lines 17 to 19, the --transport
option is accepted but never used; either remove the option or handle non-stdio
selections. Minimal fix: keep the CLI option but detect if transport != "stdio",
emit a clear warning (or warning-level logger) that only "stdio" is implemented
and that the chosen transport will fall back to "stdio", then proceed using
stdio behavior; alternatively, if you prefer to drop the feature, remove the
transport CLI parameter and any docs referencing SSE.

Comment on lines +82 to +121
def start_server(server_func: "Callable"):
nonlocal stop_server

server_task = None
server_process = None
finish = Event()

def run_server():
nonlocal server_task, server_process
watched_paths = [Path(p).resolve() for p in (file, *reload_include)]
ignored_paths = [Path(p).resolve() for p in reloader.excludes]
if all(is_relative_to_any(path, ignored_paths) or not is_relative_to_any(path, watched_paths) for path in ReactiveModule.instances):
logger.error("No files to watch for changes. The server will never reload.")

try:
if asyncio.iscoroutinefunction(server_func):
# If it's an async function, run it in an event loop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
server_task = loop.create_task(server_func())
try:
loop.run_until_complete(server_task)
except asyncio.CancelledError:
pass
finally:
loop.close()
else:
# If it's a sync function, just call it
server_func()
except KeyboardInterrupt:
pass
except Exception as e:
logger.error(f"Server error: {e}")
finally:
finish.set()

server_thread = Thread(target=run_server, daemon=True)
server_thread.start()

def stop_server():
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.

⚠️ Potential issue

Logger used before definition can raise at runtime

run_server() logs before logger is defined (logger is initialized later). Move logger setup before start_server or capture it as a default argument.

Apply one of:

-    from logging import getLogger
+    from logging import getLogger
@@
-    def start_server(server_func: "Callable"):
+    logger = getLogger("mcp.hmr")
+    def start_server(server_func: "Callable"):

Or:

-        def run_server():
+        def run_server(_logger=logger):
@@
-                logger.error("No files to watch for changes. The server will never reload.")
+                _logger.error("No files to watch for changes. The server will never reload.")
@@
-                logger.error(f"Server error: {e}")
+                _logger.error(f"Server error: {e}")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def start_server(server_func: "Callable"):
nonlocal stop_server
server_task = None
server_process = None
finish = Event()
def run_server():
nonlocal server_task, server_process
watched_paths = [Path(p).resolve() for p in (file, *reload_include)]
ignored_paths = [Path(p).resolve() for p in reloader.excludes]
if all(is_relative_to_any(path, ignored_paths) or not is_relative_to_any(path, watched_paths) for path in ReactiveModule.instances):
logger.error("No files to watch for changes. The server will never reload.")
try:
if asyncio.iscoroutinefunction(server_func):
# If it's an async function, run it in an event loop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
server_task = loop.create_task(server_func())
try:
loop.run_until_complete(server_task)
except asyncio.CancelledError:
pass
finally:
loop.close()
else:
# If it's a sync function, just call it
server_func()
except KeyboardInterrupt:
pass
except Exception as e:
logger.error(f"Server error: {e}")
finally:
finish.set()
server_thread = Thread(target=run_server, daemon=True)
server_thread.start()
def stop_server():
logger = getLogger("mcp.hmr")
def start_server(server_func: "Callable"):
nonlocal stop_server
server_task = None
server_process = None
finish = Event()
def run_server():
nonlocal server_task, server_process
watched_paths = [Path(p).resolve() for p in (file, *reload_include)]
ignored_paths = [Path(p).resolve() for p in reloader.excludes]
if all(is_relative_to_any(path, ignored_paths) or not is_relative_to_any(path, watched_paths) for path in ReactiveModule.instances):
logger.error("No files to watch for changes. The server will never reload.")
try:
if asyncio.iscoroutinefunction(server_func):
# If it's an async function, run it in an event loop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
server_task = loop.create_task(server_func())
try:
loop.run_until_complete(server_task)
except asyncio.CancelledError:
pass
finally:
loop.close()
else:
# If it's a sync function, just call it
server_func()
except KeyboardInterrupt:
pass
except Exception as e:
logger.error(f"Server error: {e}")
finally:
finish.set()
server_thread = Thread(target=run_server, daemon=True)
server_thread.start()
def stop_server():
🤖 Prompt for AI Agents
In packages/mcp-hmr/mcp_hmr/__init__.py around lines 82 to 121, run_server()
uses logger before logger is initialized which can raise at runtime; fix by
ensuring logger is defined prior to start_server (move logger setup above the
start_server definition) or bind the logger into run_server as a default
parameter (e.g., def run_server(logger=logger):) so the closure captures an
already-initialized logger; update code to either initialize logger earlier or
change the function signature to capture logger, and remove any references to a
later logger initialization within start_server.

Comment on lines +138 to +141
if __version__ >= "0.6.4":
from reactivity.hmr.core import _loader as loader
else:
loader = ReactiveModuleLoader(file) # type: ignore
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.

⚠️ Potential issue

Bug: string version comparison is incorrect

"0.10.0" >= "0.6.4" is False lexicographically. Use proper version parsing.

Apply:

-            if __version__ >= "0.6.4":
+            from packaging.version import Version
+            if Version(__version__) >= Version("0.6.4"):
                 from reactivity.hmr.core import _loader as loader
             else:
                 loader = ReactiveModuleLoader(file)  # type: ignore
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if __version__ >= "0.6.4":
from reactivity.hmr.core import _loader as loader
else:
loader = ReactiveModuleLoader(file) # type: ignore
from packaging.version import Version
if Version(__version__) >= Version("0.6.4"):
from reactivity.hmr.core import _loader as loader
else:
loader = ReactiveModuleLoader(file) # type: ignore
🤖 Prompt for AI Agents
In packages/mcp-hmr/mcp_hmr/__init__.py around lines 138 to 141, the code
compares version strings lexicographically (e.g. "0.10.0" >= "0.6.4") which is
incorrect; change to parse versions before comparing by importing a version
parser (for example from packaging.version import parse) and replace the string
comparison with parse(__version__) >= parse("0.6.4"). Ensure the import is added
at top of the file and use the parsed Version objects for the conditional so
version ordering is correct.

Comment on lines +57 to +58
- `--transport`: Transport method - `stdio` or `sse` (defaults to `stdio`)
- `--clear`: Clear the terminal before each reload
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.

⚠️ Potential issue

Doc advertises --transport sse, but code doesn’t implement it

The CLI exposes --transport and README documents SSE, but the implementation doesn’t use this option at all. Please either wire it through (and implement SSE) or remove it from docs/CLI until supported.

@CNSeniorious000
Copy link
Copy Markdown
Member Author

CNSeniorious000 commented Oct 17, 2025

@CNSeniorious000 CNSeniorious000 deleted the cursor/implement-mcp-hmr-library-and-demo-2f44 branch October 17, 2025 18:34
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.

2 participants