Skip to content

Commit f8bd1ba

Browse files
committed
Add plugin hot-reload via PluginWatcher
PluginWatcher polls a plugin directory and (un)registers MCP tools on change: new files become tools, modified files re-register under the same names with the updated handler, deleted files drop their tools. Each register/unregister already triggers notifications/tools/list_changed so connected clients see the catalogue refresh in real time.
1 parent 58cb9ea commit f8bd1ba

3 files changed

Lines changed: 204 additions & 2 deletions

File tree

je_auto_control/utils/mcp_server/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
uninstall_fake_backend,
1919
)
2020
from je_auto_control.utils.mcp_server.log_bridge import MCPLogBridge
21+
from je_auto_control.utils.mcp_server.plugin_watcher import PluginWatcher
2122
from je_auto_control.utils.mcp_server.rate_limit import RateLimiter
2223
from je_auto_control.utils.mcp_server.http_transport import (
2324
HttpMCPServer, start_mcp_http_server,
@@ -37,8 +38,8 @@
3738
"AuditLogger", "FakeState", "HttpMCPServer", "MCPContent",
3839
"MCPLogBridge", "MCPPrompt", "MCPPromptArgument", "MCPResource",
3940
"MCPServer", "MCPTool", "MCPToolAnnotations",
40-
"OperationCancelledError", "PromptProvider", "RateLimiter",
41-
"ResourceProvider", "ToolCallContext",
41+
"OperationCancelledError", "PluginWatcher", "PromptProvider",
42+
"RateLimiter", "ResourceProvider", "ToolCallContext",
4243
"build_default_tool_registry",
4344
"default_prompt_provider", "default_resource_provider",
4445
"fake_state", "install_fake_backend", "make_plugin_tool",
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Background watcher that hot-reloads plugin tools when files change.
2+
3+
Polls a plugin directory at a configurable interval, comparing each
4+
``*.py`` file's mtime to its previous reading. When a file changes
5+
(created, modified, or removed) the watcher reloads it via the
6+
plugin loader and registers / unregisters MCP tools so the model
7+
sees the updated catalogue without a server restart.
8+
"""
9+
import os
10+
import threading
11+
from typing import Any, Dict, List, Optional, Set
12+
13+
from je_auto_control.utils.logging.logging_instance import autocontrol_logger
14+
from je_auto_control.utils.mcp_server.tools.plugin_tools import (
15+
make_plugin_tool,
16+
)
17+
18+
19+
class PluginWatcher:
20+
"""Polling watcher that keeps an MCPServer's registry in sync with disk."""
21+
22+
def __init__(self, server: Any, directory: str,
23+
poll_seconds: float = 2.0) -> None:
24+
self._server = server
25+
self._directory = os.path.realpath(os.fspath(directory))
26+
self._poll_seconds = max(0.2, float(poll_seconds))
27+
self._stop = threading.Event()
28+
self._thread: Optional[threading.Thread] = None
29+
# path → (mtime, [tool_names])
30+
self._known: Dict[str, tuple] = {}
31+
32+
@property
33+
def directory(self) -> str:
34+
return self._directory
35+
36+
def start(self) -> None:
37+
if self._thread is not None and self._thread.is_alive():
38+
return
39+
if not os.path.isdir(self._directory):
40+
raise NotADirectoryError(
41+
f"plugin directory not found: {self._directory}"
42+
)
43+
self._stop.clear()
44+
self._thread = threading.Thread(
45+
target=self._run, daemon=True, name="MCPPluginWatcher",
46+
)
47+
self._thread.start()
48+
49+
def stop(self, timeout: float = 2.0) -> None:
50+
self._stop.set()
51+
if self._thread is not None:
52+
self._thread.join(timeout=timeout)
53+
self._thread = None
54+
55+
def poll_once(self) -> None:
56+
"""Run one scan-and-sync iteration. Public for tests."""
57+
seen: Set[str] = set()
58+
for entry in sorted(os.listdir(self._directory)):
59+
if not entry.endswith(".py") or entry.startswith("_"):
60+
continue
61+
full = os.path.join(self._directory, entry)
62+
if not os.path.isfile(full):
63+
continue
64+
seen.add(full)
65+
try:
66+
mtime = os.path.getmtime(full)
67+
except OSError:
68+
continue
69+
previous = self._known.get(full)
70+
if previous is None or previous[0] != mtime:
71+
self._reload_file(full, mtime)
72+
for stale in set(self._known) - seen:
73+
self._unregister_file(stale)
74+
75+
# --- internals ----------------------------------------------------------
76+
77+
def _run(self) -> None:
78+
autocontrol_logger.info(
79+
"plugin watcher started: %s (every %ss)",
80+
self._directory, self._poll_seconds,
81+
)
82+
while not self._stop.is_set():
83+
try:
84+
self.poll_once()
85+
except OSError as error:
86+
autocontrol_logger.warning(
87+
"plugin watcher poll failed: %r", error,
88+
)
89+
self._stop.wait(self._poll_seconds)
90+
autocontrol_logger.info("plugin watcher stopped")
91+
92+
def _reload_file(self, path: str, mtime: float) -> None:
93+
from je_auto_control.utils.plugin_loader.plugin_loader import (
94+
load_plugin_file,
95+
)
96+
previous = self._known.get(path)
97+
if previous is not None:
98+
for tool_name in previous[1]:
99+
self._server.unregister_tool(tool_name)
100+
try:
101+
commands = load_plugin_file(path)
102+
except (OSError, ImportError, SyntaxError) as error:
103+
autocontrol_logger.warning(
104+
"plugin %s reload failed: %r", path, error,
105+
)
106+
self._known[path] = (mtime, [])
107+
return
108+
registered: List[str] = []
109+
for raw_name, handler in commands.items():
110+
tool = make_plugin_tool(raw_name, handler)
111+
self._server.register_tool(tool)
112+
registered.append(tool.name)
113+
self._known[path] = (mtime, registered)
114+
autocontrol_logger.info(
115+
"plugin %s reloaded → %d tools", os.path.basename(path),
116+
len(registered),
117+
)
118+
119+
def _unregister_file(self, path: str) -> None:
120+
previous = self._known.pop(path, None)
121+
if previous is None:
122+
return
123+
for tool_name in previous[1]:
124+
self._server.unregister_tool(tool_name)
125+
autocontrol_logger.info(
126+
"plugin %s removed → %d tools dropped",
127+
os.path.basename(path), len(previous[1]),
128+
)
129+
130+
131+
__all__ = ["PluginWatcher"]
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Tests for the MCP plugin hot-reload watcher."""
2+
import time
3+
4+
from je_auto_control.utils.mcp_server.plugin_watcher import PluginWatcher
5+
from je_auto_control.utils.mcp_server.server import MCPServer
6+
7+
8+
def _write(path, body):
9+
path.write_text(body, encoding="utf-8")
10+
# Bump mtime to ensure the watcher picks it up even on coarse FSes.
11+
now = time.time()
12+
import os
13+
os.utime(path, (now, now))
14+
15+
16+
def test_watcher_registers_tools_for_existing_plugins(tmp_path):
17+
plugin = tmp_path / "demo.py"
18+
_write(plugin, "def AC_hello(name='world'):\n return f'hi {name}'\n")
19+
server = MCPServer(tools=[])
20+
watcher = PluginWatcher(server, str(tmp_path), poll_seconds=0.1)
21+
watcher.poll_once()
22+
assert server._tools.get("plugin_ac_hello") is not None
23+
24+
25+
def test_watcher_picks_up_new_files(tmp_path):
26+
server = MCPServer(tools=[])
27+
watcher = PluginWatcher(server, str(tmp_path), poll_seconds=0.1)
28+
watcher.poll_once()
29+
plugin = tmp_path / "added.py"
30+
_write(plugin, "def AC_added():\n return 'late'\n")
31+
watcher.poll_once()
32+
assert "plugin_ac_added" in server._tools
33+
34+
35+
def test_watcher_drops_tools_when_file_removed(tmp_path):
36+
plugin = tmp_path / "soon.py"
37+
_write(plugin, "def AC_soon():\n return 'gone soon'\n")
38+
server = MCPServer(tools=[])
39+
watcher = PluginWatcher(server, str(tmp_path), poll_seconds=0.1)
40+
watcher.poll_once()
41+
assert "plugin_ac_soon" in server._tools
42+
plugin.unlink()
43+
watcher.poll_once()
44+
assert "plugin_ac_soon" not in server._tools
45+
46+
47+
def test_watcher_reloads_after_mtime_change(tmp_path):
48+
plugin = tmp_path / "evolving.py"
49+
_write(plugin, "def AC_evolve():\n return 1\n")
50+
server = MCPServer(tools=[])
51+
watcher = PluginWatcher(server, str(tmp_path), poll_seconds=0.1)
52+
watcher.poll_once()
53+
first = server._tools["plugin_ac_evolve"].handler
54+
# Rewrite with a new function body and bump mtime.
55+
_write(plugin, "def AC_evolve():\n return 2\n")
56+
watcher.poll_once()
57+
second = server._tools["plugin_ac_evolve"].handler
58+
assert first is not second
59+
60+
61+
def test_watcher_start_requires_existing_directory(tmp_path):
62+
server = MCPServer(tools=[])
63+
watcher = PluginWatcher(server, str(tmp_path / "ghost"))
64+
try:
65+
watcher.start()
66+
except NotADirectoryError:
67+
pass
68+
else:
69+
watcher.stop(timeout=0.5)
70+
raise AssertionError("expected NotADirectoryError")

0 commit comments

Comments
 (0)