mcpmon 0.1.0 → 0.3.0

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.
@@ -0,0 +1,493 @@
1
+ """Unit and integration tests for mcpmon."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import time
11
+ from pathlib import Path
12
+ from unittest.mock import MagicMock, patch
13
+
14
+ import pytest
15
+
16
+ # Add parent directory to path for imports
17
+ sys.path.insert(0, str(Path(__file__).parent.parent))
18
+
19
+ from mcpmon import (
20
+ Logger,
21
+ LogLevel,
22
+ get_change_type_name,
23
+ should_reload,
24
+ )
25
+
26
+
27
+ # =============================================================================
28
+ # Unit Tests - Logger
29
+ # =============================================================================
30
+
31
+
32
+ class TestLogLevel:
33
+ """Test LogLevel enum."""
34
+
35
+ def test_level_ordering(self):
36
+ """Log levels should be ordered: QUIET < NORMAL < VERBOSE < DEBUG."""
37
+ assert LogLevel.QUIET < LogLevel.NORMAL
38
+ assert LogLevel.NORMAL < LogLevel.VERBOSE
39
+ assert LogLevel.VERBOSE < LogLevel.DEBUG
40
+
41
+ def test_level_values(self):
42
+ """Log levels should have expected numeric values."""
43
+ assert LogLevel.QUIET == 0
44
+ assert LogLevel.NORMAL == 1
45
+ assert LogLevel.VERBOSE == 2
46
+ assert LogLevel.DEBUG == 3
47
+
48
+
49
+ class TestLogger:
50
+ """Test Logger class."""
51
+
52
+ def test_default_settings(self):
53
+ """Logger should have sensible defaults."""
54
+ logger = Logger()
55
+ assert logger.level == LogLevel.NORMAL
56
+ assert logger.show_timestamps is False
57
+ assert logger.log_file is None
58
+
59
+ def test_format_basic(self):
60
+ """Format should include [mcpmon] prefix."""
61
+ logger = Logger()
62
+ msg = logger._format("test message")
63
+ assert msg.startswith("[mcpmon]")
64
+ assert "test message" in msg
65
+
66
+ def test_format_with_pid(self):
67
+ """Format should include PID when provided."""
68
+ logger = Logger()
69
+ msg = logger._format("test", pid=12345)
70
+ assert "pid:12345" in msg
71
+
72
+ def test_format_with_timestamps(self):
73
+ """Format should include timestamp when enabled."""
74
+ logger = Logger(show_timestamps=True)
75
+ msg = logger._format("test")
76
+ # Timestamp format is HH:MM:SS
77
+ import re
78
+ assert re.search(r"\d{2}:\d{2}:\d{2}", msg)
79
+
80
+ def test_format_without_timestamps(self):
81
+ """Format should not include timestamp when disabled."""
82
+ logger = Logger(show_timestamps=False)
83
+ msg = logger._format("test")
84
+ import re
85
+ # Should not have time pattern in basic format
86
+ assert not re.search(r"\d{2}:\d{2}:\d{2}", msg)
87
+
88
+ def test_info_respects_level(self, capsys):
89
+ """info() should respect log level."""
90
+ logger = Logger(level=LogLevel.QUIET)
91
+ logger.info("should not appear")
92
+ captured = capsys.readouterr()
93
+ assert "should not appear" not in captured.err
94
+
95
+ logger = Logger(level=LogLevel.NORMAL)
96
+ logger.info("should appear")
97
+ captured = capsys.readouterr()
98
+ assert "should appear" in captured.err
99
+
100
+ def test_verbose_respects_level(self, capsys):
101
+ """verbose() should respect log level."""
102
+ logger = Logger(level=LogLevel.NORMAL)
103
+ logger.verbose("should not appear")
104
+ captured = capsys.readouterr()
105
+ assert "should not appear" not in captured.err
106
+
107
+ logger = Logger(level=LogLevel.VERBOSE)
108
+ logger.verbose("should appear")
109
+ captured = capsys.readouterr()
110
+ assert "should appear" in captured.err
111
+
112
+ def test_debug_respects_level(self, capsys):
113
+ """debug() should respect log level."""
114
+ logger = Logger(level=LogLevel.VERBOSE)
115
+ logger.debug("should not appear")
116
+ captured = capsys.readouterr()
117
+ assert "should not appear" not in captured.err
118
+
119
+ logger = Logger(level=LogLevel.DEBUG)
120
+ logger.debug("should appear")
121
+ captured = capsys.readouterr()
122
+ assert "DEBUG:" in captured.err
123
+ assert "should appear" in captured.err
124
+
125
+ def test_error_always_shown(self, capsys):
126
+ """error() should always be shown, even at QUIET level."""
127
+ logger = Logger(level=LogLevel.QUIET)
128
+ logger.error("error message")
129
+ captured = capsys.readouterr()
130
+ assert "ERROR:" in captured.err
131
+ assert "error message" in captured.err
132
+
133
+ def test_log_file_write(self):
134
+ """Logger should write to file when configured."""
135
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False) as f:
136
+ log_path = Path(f.name)
137
+
138
+ try:
139
+ logger = Logger(log_file=log_path)
140
+ logger.open_file()
141
+ logger.info("file test message")
142
+ logger.close_file()
143
+
144
+ content = log_path.read_text()
145
+ assert "file test message" in content
146
+ # File logs should always have timestamps
147
+ import re
148
+ assert re.search(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", content)
149
+ finally:
150
+ log_path.unlink(missing_ok=True)
151
+
152
+
153
+ # =============================================================================
154
+ # Unit Tests - File Watching
155
+ # =============================================================================
156
+
157
+
158
+ class TestShouldReload:
159
+ """Test should_reload function."""
160
+
161
+ def test_matching_extension(self):
162
+ """Should return True for matching extensions."""
163
+ from watchfiles import Change
164
+
165
+ changes = {(Change.modified, "/path/to/file.py")}
166
+ extensions = {"py"}
167
+
168
+ reload, matches = should_reload(changes, extensions)
169
+ assert reload is True
170
+ assert len(matches) == 1
171
+ assert matches[0][0] == "modified"
172
+ assert matches[0][1] == "/path/to/file.py"
173
+
174
+ def test_non_matching_extension(self):
175
+ """Should return False for non-matching extensions."""
176
+ from watchfiles import Change
177
+
178
+ changes = {(Change.modified, "/path/to/file.txt")}
179
+ extensions = {"py"}
180
+
181
+ reload, matches = should_reload(changes, extensions)
182
+ assert reload is False
183
+ assert len(matches) == 0
184
+
185
+ def test_multiple_extensions(self):
186
+ """Should match multiple configured extensions."""
187
+ from watchfiles import Change
188
+
189
+ extensions = {"py", "json", "yaml"}
190
+
191
+ for ext in ["py", "json", "yaml"]:
192
+ changes = {(Change.modified, f"/path/to/file.{ext}")}
193
+ reload, _ = should_reload(changes, extensions)
194
+ assert reload is True
195
+
196
+ def test_deleted_files_ignored(self):
197
+ """Should not reload for deleted files."""
198
+ from watchfiles import Change
199
+
200
+ changes = {(Change.deleted, "/path/to/file.py")}
201
+ extensions = {"py"}
202
+
203
+ reload, matches = should_reload(changes, extensions)
204
+ assert reload is False
205
+
206
+ def test_added_files_trigger_reload(self):
207
+ """Should reload for newly added files."""
208
+ from watchfiles import Change
209
+
210
+ changes = {(Change.added, "/path/to/new_file.py")}
211
+ extensions = {"py"}
212
+
213
+ reload, matches = should_reload(changes, extensions)
214
+ assert reload is True
215
+ assert matches[0][0] == "added"
216
+
217
+ def test_multiple_changes(self):
218
+ """Should handle multiple file changes."""
219
+ from watchfiles import Change
220
+
221
+ changes = {
222
+ (Change.modified, "/path/to/file1.py"),
223
+ (Change.modified, "/path/to/file2.py"),
224
+ (Change.modified, "/path/to/file3.txt"), # Should not match
225
+ }
226
+ extensions = {"py"}
227
+
228
+ reload, matches = should_reload(changes, extensions)
229
+ assert reload is True
230
+ assert len(matches) == 2
231
+
232
+
233
+ class TestGetChangeTypeName:
234
+ """Test get_change_type_name function."""
235
+
236
+ def test_change_types(self):
237
+ """Should return human-readable change type names."""
238
+ from watchfiles import Change
239
+
240
+ assert get_change_type_name(Change.added) == "added"
241
+ assert get_change_type_name(Change.modified) == "modified"
242
+ assert get_change_type_name(Change.deleted) == "deleted"
243
+
244
+
245
+ # =============================================================================
246
+ # Integration Tests
247
+ # =============================================================================
248
+
249
+
250
+ class TestIntegration:
251
+ """Integration tests for mcpmon."""
252
+
253
+ @pytest.fixture
254
+ def temp_dir(self):
255
+ """Create a temporary directory for testing."""
256
+ with tempfile.TemporaryDirectory() as tmpdir:
257
+ yield Path(tmpdir)
258
+
259
+ @pytest.fixture
260
+ def dummy_server(self, temp_dir):
261
+ """Create a dummy server script that logs when started."""
262
+ server_script = temp_dir / "server.py"
263
+ server_script.write_text("""
264
+ import sys
265
+ import time
266
+ import signal
267
+
268
+ print(f"[test-server] Started (pid={__import__('os').getpid()})", file=sys.stderr)
269
+ sys.stderr.flush()
270
+
271
+ def handle_term(sig, frame):
272
+ print("[test-server] Received SIGTERM", file=sys.stderr)
273
+ sys.exit(0)
274
+
275
+ signal.signal(signal.SIGTERM, handle_term)
276
+
277
+ while True:
278
+ time.sleep(0.1)
279
+ """)
280
+ return server_script
281
+
282
+ def test_mcpmon_starts_server(self, temp_dir, dummy_server):
283
+ """mcpmon should start the server subprocess."""
284
+ proc = subprocess.Popen(
285
+ [
286
+ sys.executable,
287
+ "-m", "mcpmon",
288
+ "--watch", str(temp_dir),
289
+ "--", sys.executable, str(dummy_server),
290
+ ],
291
+ stderr=subprocess.PIPE,
292
+ stdout=subprocess.PIPE,
293
+ cwd=Path(__file__).parent.parent,
294
+ )
295
+
296
+ try:
297
+ # Wait for startup
298
+ time.sleep(2)
299
+
300
+ # Check that mcpmon is running
301
+ assert proc.poll() is None, "mcpmon should still be running"
302
+
303
+ finally:
304
+ proc.terminate()
305
+ proc.wait(timeout=5)
306
+
307
+ def test_mcpmon_restarts_on_change(self, temp_dir, dummy_server):
308
+ """mcpmon should restart server when watched file changes."""
309
+ # Create a watched file
310
+ watched_file = temp_dir / "watched.py"
311
+ watched_file.write_text("# initial")
312
+
313
+ log_file = temp_dir / "mcpmon.log"
314
+
315
+ proc = subprocess.Popen(
316
+ [
317
+ sys.executable,
318
+ "-m", "mcpmon",
319
+ "--watch", str(temp_dir),
320
+ "--ext", "py",
321
+ "--log-file", str(log_file),
322
+ "--", sys.executable, str(dummy_server),
323
+ ],
324
+ stderr=subprocess.PIPE,
325
+ stdout=subprocess.PIPE,
326
+ cwd=Path(__file__).parent.parent,
327
+ )
328
+
329
+ try:
330
+ # Wait for initial startup
331
+ time.sleep(2)
332
+
333
+ # Trigger a reload by modifying the watched file
334
+ watched_file.write_text("# modified")
335
+
336
+ # Wait for restart
337
+ time.sleep(2)
338
+
339
+ # Check log file for restart message
340
+ log_content = log_file.read_text()
341
+ assert "Restart #1" in log_content, f"Expected restart in log: {log_content}"
342
+
343
+ finally:
344
+ proc.terminate()
345
+ proc.wait(timeout=5)
346
+
347
+ def test_mcpmon_ignores_non_matching_files(self, temp_dir, dummy_server):
348
+ """mcpmon should ignore changes to non-matching extensions."""
349
+ # Create a non-matching file
350
+ other_file = temp_dir / "readme.txt"
351
+ other_file.write_text("initial")
352
+
353
+ log_file = temp_dir / "mcpmon.log"
354
+
355
+ proc = subprocess.Popen(
356
+ [
357
+ sys.executable,
358
+ "-m", "mcpmon",
359
+ "--watch", str(temp_dir),
360
+ "--ext", "py", # Only watch .py files
361
+ "--debug",
362
+ "--log-file", str(log_file),
363
+ "--", sys.executable, str(dummy_server),
364
+ ],
365
+ stderr=subprocess.PIPE,
366
+ stdout=subprocess.PIPE,
367
+ cwd=Path(__file__).parent.parent,
368
+ )
369
+
370
+ try:
371
+ # Wait for initial startup
372
+ time.sleep(2)
373
+
374
+ # Modify the non-matching file
375
+ other_file.write_text("modified")
376
+
377
+ # Wait a bit
378
+ time.sleep(2)
379
+
380
+ # Check that no restart occurred
381
+ log_content = log_file.read_text()
382
+ assert "Restart #1" not in log_content, "Should not restart for .txt file"
383
+ # But should log that it was ignored (debug mode)
384
+ assert "Ignored" in log_content or "readme.txt" in log_content
385
+
386
+ finally:
387
+ proc.terminate()
388
+ proc.wait(timeout=5)
389
+
390
+ def test_mcpmon_graceful_shutdown(self, temp_dir, dummy_server):
391
+ """mcpmon should handle SIGTERM gracefully."""
392
+ log_file = temp_dir / "mcpmon.log"
393
+
394
+ proc = subprocess.Popen(
395
+ [
396
+ sys.executable,
397
+ "-m", "mcpmon",
398
+ "--watch", str(temp_dir),
399
+ "--log-file", str(log_file),
400
+ "--", sys.executable, str(dummy_server),
401
+ ],
402
+ stderr=subprocess.PIPE,
403
+ stdout=subprocess.PIPE,
404
+ cwd=Path(__file__).parent.parent,
405
+ )
406
+
407
+ try:
408
+ # Wait for startup
409
+ time.sleep(2)
410
+
411
+ # Send SIGTERM
412
+ proc.terminate()
413
+
414
+ # Wait for graceful shutdown
415
+ proc.wait(timeout=5)
416
+
417
+ # Check log for shutdown message
418
+ log_content = log_file.read_text()
419
+ assert "Shutdown complete" in log_content
420
+
421
+ except subprocess.TimeoutExpired:
422
+ proc.kill()
423
+ pytest.fail("mcpmon did not shut down gracefully")
424
+
425
+ def test_mcpmon_timestamps_option(self, temp_dir, dummy_server):
426
+ """--timestamps should add timestamps to output."""
427
+ log_file = temp_dir / "mcpmon.log"
428
+
429
+ proc = subprocess.Popen(
430
+ [
431
+ sys.executable,
432
+ "-m", "mcpmon",
433
+ "--watch", str(temp_dir),
434
+ "--timestamps",
435
+ "--log-file", str(log_file),
436
+ "--", sys.executable, str(dummy_server),
437
+ ],
438
+ stderr=subprocess.PIPE,
439
+ stdout=subprocess.PIPE,
440
+ cwd=Path(__file__).parent.parent,
441
+ )
442
+
443
+ try:
444
+ time.sleep(2)
445
+ finally:
446
+ proc.terminate()
447
+ proc.wait(timeout=5)
448
+
449
+ # Log file always has full timestamps
450
+ import re
451
+ log_content = log_file.read_text()
452
+ assert re.search(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", log_content)
453
+
454
+
455
+ # =============================================================================
456
+ # CLI Argument Tests
457
+ # =============================================================================
458
+
459
+
460
+ class TestCLI:
461
+ """Test CLI argument parsing."""
462
+
463
+ def test_help_flag(self):
464
+ """--help should show usage and exit 0."""
465
+ result = subprocess.run(
466
+ [sys.executable, "-m", "mcpmon", "--help"],
467
+ capture_output=True,
468
+ text=True,
469
+ cwd=Path(__file__).parent.parent,
470
+ )
471
+ assert result.returncode == 0
472
+ assert "Usage:" in result.stdout or "usage:" in result.stdout
473
+
474
+ def test_missing_command_error(self):
475
+ """Should error if no command specified."""
476
+ result = subprocess.run(
477
+ [sys.executable, "-m", "mcpmon", "--watch", "."],
478
+ capture_output=True,
479
+ text=True,
480
+ cwd=Path(__file__).parent.parent,
481
+ )
482
+ assert result.returncode != 0
483
+ assert "command" in result.stderr.lower() or "No command" in result.stderr
484
+
485
+ def test_version_in_help(self):
486
+ """Help should include examples."""
487
+ result = subprocess.run(
488
+ [sys.executable, "-m", "mcpmon", "--help"],
489
+ capture_output=True,
490
+ text=True,
491
+ cwd=Path(__file__).parent.parent,
492
+ )
493
+ assert "Examples:" in result.stdout
@@ -1,50 +0,0 @@
1
- name: Publish to npm
2
-
3
- on:
4
- release:
5
- types: [published]
6
-
7
- jobs:
8
- publish-npm:
9
- runs-on: ubuntu-latest
10
- permissions:
11
- contents: read
12
- id-token: write
13
- steps:
14
- - uses: actions/checkout@v4
15
-
16
- - uses: actions/setup-node@v4
17
- with:
18
- node-version: '20'
19
- registry-url: 'https://registry.npmjs.org'
20
-
21
- - name: Publish to npm
22
- run: npm publish --access public
23
-
24
- build-binaries:
25
- runs-on: ${{ matrix.os }}
26
- permissions:
27
- contents: write
28
- strategy:
29
- matrix:
30
- include:
31
- - os: ubuntu-latest
32
- target: linux-x64
33
- - os: macos-latest
34
- target: darwin-arm64
35
- - os: macos-13
36
- target: darwin-x64
37
- steps:
38
- - uses: actions/checkout@v4
39
-
40
- - uses: oven-sh/setup-bun@v2
41
- with:
42
- bun-version: latest
43
-
44
- - name: Build binary
45
- run: bun build --compile mcpmon.ts --outfile mcpmon-${{ matrix.target }}
46
-
47
- - name: Upload to release
48
- run: gh release upload ${{ github.ref_name }} mcpmon-${{ matrix.target }} --clobber
49
- env:
50
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -1,26 +0,0 @@
1
- name: Publish to PyPI
2
-
3
- on:
4
- release:
5
- types: [published]
6
-
7
- jobs:
8
- publish:
9
- runs-on: ubuntu-latest
10
- permissions:
11
- id-token: write
12
- steps:
13
- - uses: actions/checkout@v4
14
-
15
- - uses: actions/setup-python@v5
16
- with:
17
- python-version: "3.12"
18
-
19
- - name: Install build
20
- run: pip install build
21
-
22
- - name: Build package
23
- run: python -m build
24
-
25
- - name: Publish to PyPI
26
- uses: pypa/gh-action-pypi-publish@release/v1