agent-mcp-gateway 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
src/config_watcher.py ADDED
@@ -0,0 +1,296 @@
1
+ """Configuration file watcher for hot reloading."""
2
+
3
+ import logging
4
+ import threading
5
+ from pathlib import Path
6
+ from typing import Callable
7
+
8
+ from watchdog.events import FileSystemEvent, FileSystemEventHandler
9
+ from watchdog.observers import Observer
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class ConfigWatcher:
15
+ """Watches configuration files for changes and triggers reload callbacks.
16
+
17
+ The watcher monitors two configuration files (.mcp.json and .mcp-gateway-rules.json)
18
+ and calls the appropriate callback when changes are detected. It implements debouncing
19
+ to handle rapid file system events (e.g., editor saves that create temporary files).
20
+
21
+ Thread Safety:
22
+ The watchdog Observer runs in a separate thread. Callbacks are invoked from that
23
+ thread, so they must be thread-safe. The debouncing timer also runs in a separate
24
+ thread context.
25
+
26
+ Example:
27
+ ```python
28
+ def on_mcp_config_changed(config_path: str):
29
+ logger.info(f"Reloading MCP config from: {config_path}")
30
+ # Reload logic here
31
+
32
+ def on_rules_changed(rules_path: str):
33
+ logger.info(f"Reloading gateway rules from: {rules_path}")
34
+ # Reload logic here
35
+
36
+ watcher = ConfigWatcher(
37
+ mcp_config_path="/path/to/.mcp.json",
38
+ gateway_rules_path="/path/to/.mcp-gateway-rules.json",
39
+ on_mcp_config_changed=on_mcp_config_changed,
40
+ on_gateway_rules_changed=on_rules_changed,
41
+ debounce_seconds=0.3
42
+ )
43
+
44
+ watcher.start()
45
+ # ... application runs ...
46
+ watcher.stop()
47
+ ```
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ mcp_config_path: str,
53
+ gateway_rules_path: str,
54
+ on_mcp_config_changed: Callable[[str], None],
55
+ on_gateway_rules_changed: Callable[[str], None],
56
+ debounce_seconds: float = 0.1, # Reduced from 0.3 for faster response
57
+ ):
58
+ """Initialize the configuration file watcher.
59
+
60
+ Args:
61
+ mcp_config_path: Path to .mcp.json file (can be relative or absolute,
62
+ will be resolved to absolute path)
63
+ gateway_rules_path: Path to .mcp-gateway-rules.json file (can be relative or
64
+ absolute, will be resolved to absolute path)
65
+ on_mcp_config_changed: Callback invoked when MCP config changes.
66
+ Receives the resolved absolute config file path as argument.
67
+ on_gateway_rules_changed: Callback invoked when gateway rules change.
68
+ Receives the resolved absolute rules file path as argument.
69
+ debounce_seconds: Time to wait after last file event before triggering
70
+ callback. Prevents multiple rapid callbacks from editor autosaves.
71
+ Default: 0.3 seconds.
72
+ """
73
+ # Validate and normalize paths to absolute
74
+ self.mcp_config_path = Path(mcp_config_path).resolve()
75
+ self.gateway_rules_path = Path(gateway_rules_path).resolve()
76
+
77
+ # Store callbacks
78
+ self.on_mcp_config_changed = on_mcp_config_changed
79
+ self.on_gateway_rules_changed = on_gateway_rules_changed
80
+ self.debounce_seconds = debounce_seconds
81
+
82
+ # Initialize observer and handler
83
+ self.observer: Observer | None = None
84
+ self._event_handler = _ConfigFileEventHandler(self)
85
+ self._lock = threading.Lock()
86
+
87
+ # Debouncing state (protected by lock)
88
+ self._pending_timers: dict[str, threading.Timer] = {}
89
+
90
+ logger.debug(
91
+ f"ConfigWatcher initialized for MCP config: {self.mcp_config_path}, "
92
+ f"Gateway rules: {self.gateway_rules_path}"
93
+ )
94
+
95
+ def start(self) -> None:
96
+ """Start watching the configuration files.
97
+
98
+ Creates a watchdog Observer and starts monitoring the directories containing
99
+ the configuration files. The observer runs in a separate thread.
100
+
101
+ Raises:
102
+ RuntimeError: If watcher is already running
103
+ OSError: If directories cannot be watched (e.g., permission denied)
104
+ """
105
+ with self._lock:
106
+ if self.observer is not None:
107
+ raise RuntimeError("ConfigWatcher is already running")
108
+
109
+ # Get parent directories to watch
110
+ mcp_config_dir = self.mcp_config_path.parent
111
+ rules_dir = self.gateway_rules_path.parent
112
+
113
+ # Create and start observer
114
+ self.observer = Observer()
115
+
116
+ # Watch MCP config directory
117
+ try:
118
+ self.observer.schedule(
119
+ self._event_handler, str(mcp_config_dir), recursive=False
120
+ )
121
+ logger.debug(f"Watching directory: {mcp_config_dir}")
122
+ except OSError as e:
123
+ raise OSError(
124
+ f"Cannot watch MCP config directory {mcp_config_dir}: {e}"
125
+ ) from e
126
+
127
+ # Watch gateway rules directory (if different)
128
+ if rules_dir != mcp_config_dir:
129
+ try:
130
+ self.observer.schedule(
131
+ self._event_handler, str(rules_dir), recursive=False
132
+ )
133
+ logger.debug(f"Watching directory: {rules_dir}")
134
+ except OSError as e:
135
+ raise OSError(
136
+ f"Cannot watch gateway rules directory {rules_dir}: {e}"
137
+ ) from e
138
+
139
+ self.observer.start()
140
+ logger.info(
141
+ f"ConfigWatcher started monitoring: {self.mcp_config_path.name}, "
142
+ f"{self.gateway_rules_path.name}"
143
+ )
144
+
145
+ def stop(self) -> None:
146
+ """Stop watching the configuration files and clean up resources.
147
+
148
+ Stops the observer thread and cancels any pending debounce timers.
149
+ This method is idempotent and safe to call multiple times.
150
+ """
151
+ with self._lock:
152
+ # Cancel all pending timers
153
+ for timer in self._pending_timers.values():
154
+ timer.cancel()
155
+ self._pending_timers.clear()
156
+
157
+ # Stop and cleanup observer
158
+ if self.observer is not None:
159
+ self.observer.stop()
160
+ self.observer.join(timeout=2.0)
161
+ self.observer = None
162
+ logger.info("ConfigWatcher stopped")
163
+
164
+ def _handle_file_change(self, file_path: Path) -> None:
165
+ """Handle a file change event with debouncing.
166
+
167
+ This method is called by the event handler when a relevant file changes.
168
+ It implements debouncing by canceling any pending timer for the file and
169
+ starting a new one. The callback is only invoked after the debounce period
170
+ elapses without new events.
171
+
172
+ Args:
173
+ file_path: Absolute path to the file that changed
174
+
175
+ Thread Safety:
176
+ Must be called with self._lock held, or from event handler which
177
+ manages its own locking.
178
+ """
179
+ file_path = file_path.resolve()
180
+ logger.debug(f"File change detected: {file_path}")
181
+
182
+ # Determine which callback to use
183
+ callback: Callable[[str], None] | None = None
184
+ if file_path == self.mcp_config_path:
185
+ callback = self.on_mcp_config_changed
186
+ callback_name = "on_mcp_config_changed"
187
+ elif file_path == self.gateway_rules_path:
188
+ callback = self.on_gateway_rules_changed
189
+ callback_name = "on_gateway_rules_changed"
190
+ else:
191
+ # Not one of our watched files (could be temp file, other file in dir, etc.)
192
+ logger.debug(f"Ignoring change to non-watched file: {file_path}")
193
+ return
194
+
195
+ with self._lock:
196
+ # Cancel existing timer for this file if any
197
+ file_key = str(file_path)
198
+ if file_key in self._pending_timers:
199
+ self._pending_timers[file_key].cancel()
200
+ logger.debug(f"Cancelled previous debounce timer for: {file_path.name}")
201
+
202
+ # Create new debounced callback
203
+ def debounced_callback():
204
+ logger.info(
205
+ f"Config file changed after debounce period: {file_path.name}"
206
+ )
207
+ try:
208
+ callback(str(file_path))
209
+ logger.debug(f"Successfully invoked {callback_name}")
210
+ except Exception as e:
211
+ logger.error(
212
+ f"Error in {callback_name} callback: {e}", exc_info=True
213
+ )
214
+ finally:
215
+ # Clean up timer reference
216
+ with self._lock:
217
+ self._pending_timers.pop(file_key, None)
218
+
219
+ # Schedule new timer
220
+ timer = threading.Timer(self.debounce_seconds, debounced_callback)
221
+ timer.daemon = True
222
+ self._pending_timers[file_key] = timer
223
+ timer.start()
224
+ logger.debug(
225
+ f"Scheduled debounced callback for {file_path.name} "
226
+ f"in {self.debounce_seconds}s"
227
+ )
228
+
229
+
230
+ class _ConfigFileEventHandler(FileSystemEventHandler):
231
+ """Internal event handler for file system events.
232
+
233
+ This handler filters file system events and forwards relevant ones
234
+ (modified, created, moved) to the ConfigWatcher for debounced processing.
235
+ """
236
+
237
+ def __init__(self, watcher: ConfigWatcher):
238
+ """Initialize the event handler.
239
+
240
+ Args:
241
+ watcher: The ConfigWatcher instance to notify of changes
242
+ """
243
+ super().__init__()
244
+ self.watcher = watcher
245
+
246
+ def on_modified(self, event: FileSystemEvent) -> None:
247
+ """Handle file modification events.
248
+
249
+ Args:
250
+ event: The file system event
251
+ """
252
+ if not event.is_directory:
253
+ self._handle_event(event.src_path)
254
+
255
+ def on_created(self, event: FileSystemEvent) -> None:
256
+ """Handle file creation events.
257
+
258
+ Some editors create new files when saving (write to temp, rename to target).
259
+
260
+ Args:
261
+ event: The file system event
262
+ """
263
+ if not event.is_directory:
264
+ self._handle_event(event.src_path)
265
+
266
+ def on_moved(self, event: FileSystemEvent) -> None:
267
+ """Handle file move/rename events.
268
+
269
+ Many editors use atomic writes: write to temp file, then rename to target.
270
+ We need to detect when a file is moved TO our watched config file path.
271
+
272
+ Args:
273
+ event: The file system event
274
+ """
275
+ if not event.is_directory and hasattr(event, "dest_path"):
276
+ # Check if destination is one of our watched files
277
+ self._handle_event(event.dest_path)
278
+
279
+ def _handle_event(self, path: str) -> None:
280
+ """Process a file system event path.
281
+
282
+ Args:
283
+ path: Path to the file that changed
284
+ """
285
+ try:
286
+ file_path = Path(path).resolve()
287
+ logger.debug(f"[EventHandler] Processing event for: {path}")
288
+ logger.debug(f"[EventHandler] Resolved to: {file_path}")
289
+ logger.debug(f"[EventHandler] Watched MCP config: {self.watcher.mcp_config_path}")
290
+ logger.debug(f"[EventHandler] Watched gateway rules: {self.watcher.gateway_rules_path}")
291
+ logger.debug(f"[EventHandler] Matches MCP config: {file_path == self.watcher.mcp_config_path}")
292
+ logger.debug(f"[EventHandler] Matches gateway rules: {file_path == self.watcher.gateway_rules_path}")
293
+
294
+ self.watcher._handle_file_change(file_path)
295
+ except Exception as e:
296
+ logger.error(f"Error handling file system event for {path}: {e}", exc_info=True)