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.
- agent_mcp_gateway-0.2.1.dist-info/METADATA +1330 -0
- agent_mcp_gateway-0.2.1.dist-info/RECORD +18 -0
- agent_mcp_gateway-0.2.1.dist-info/WHEEL +4 -0
- agent_mcp_gateway-0.2.1.dist-info/entry_points.txt +2 -0
- agent_mcp_gateway-0.2.1.dist-info/licenses/LICENSE +21 -0
- src/CONFIG_README.md +351 -0
- src/__init__.py +1 -0
- src/audit.py +94 -0
- src/config/.mcp-gateway-rules.json.example +59 -0
- src/config/.mcp.json.example +30 -0
- src/config.py +849 -0
- src/config_watcher.py +296 -0
- src/gateway.py +547 -0
- src/main.py +570 -0
- src/metrics.py +299 -0
- src/middleware.py +166 -0
- src/policy.py +500 -0
- src/proxy.py +649 -0
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)
|