mcpmon 0.1.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.
- package/.github/workflows/publish-npm.yml +50 -0
- package/.github/workflows/publish.yml +26 -0
- package/.github/workflows/release.yml +61 -0
- package/README.md +67 -0
- package/mcpmon.py +113 -0
- package/mcpmon.ts +113 -0
- package/package.json +23 -0
- package/pyproject.toml +32 -0
|
@@ -0,0 +1,50 @@
|
|
|
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 }}
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
inputs:
|
|
6
|
+
bump:
|
|
7
|
+
description: 'Version bump type'
|
|
8
|
+
required: true
|
|
9
|
+
default: 'patch'
|
|
10
|
+
type: choice
|
|
11
|
+
options:
|
|
12
|
+
- patch
|
|
13
|
+
- minor
|
|
14
|
+
- major
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
release:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
permissions:
|
|
20
|
+
contents: write
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v4
|
|
23
|
+
with:
|
|
24
|
+
fetch-depth: 0
|
|
25
|
+
|
|
26
|
+
- uses: actions/setup-python@v5
|
|
27
|
+
with:
|
|
28
|
+
python-version: "3.12"
|
|
29
|
+
|
|
30
|
+
- name: Get current version
|
|
31
|
+
id: current
|
|
32
|
+
run: |
|
|
33
|
+
VERSION=$(grep -Po '(?<=version = ")[^"]+' pyproject.toml)
|
|
34
|
+
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
35
|
+
|
|
36
|
+
- name: Bump version
|
|
37
|
+
id: bump
|
|
38
|
+
run: |
|
|
39
|
+
IFS='.' read -r major minor patch <<< "${{ steps.current.outputs.version }}"
|
|
40
|
+
case "${{ inputs.bump }}" in
|
|
41
|
+
major) major=$((major + 1)); minor=0; patch=0 ;;
|
|
42
|
+
minor) minor=$((minor + 1)); patch=0 ;;
|
|
43
|
+
patch) patch=$((patch + 1)) ;;
|
|
44
|
+
esac
|
|
45
|
+
NEW_VERSION="$major.$minor.$patch"
|
|
46
|
+
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
|
47
|
+
sed -i "s/version = \"${{ steps.current.outputs.version }}\"/version = \"$NEW_VERSION\"/" pyproject.toml
|
|
48
|
+
|
|
49
|
+
- name: Commit and tag
|
|
50
|
+
run: |
|
|
51
|
+
git config user.name "github-actions[bot]"
|
|
52
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
53
|
+
git add pyproject.toml
|
|
54
|
+
git commit -m "(release): v${{ steps.bump.outputs.version }}"
|
|
55
|
+
git tag "v${{ steps.bump.outputs.version }}"
|
|
56
|
+
git push && git push --tags
|
|
57
|
+
|
|
58
|
+
- name: Create release
|
|
59
|
+
run: gh release create "v${{ steps.bump.outputs.version }}" --generate-notes
|
|
60
|
+
env:
|
|
61
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# mcpmon
|
|
2
|
+
|
|
3
|
+
Hot reload for MCP servers. Like nodemon, but for MCP.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Bun (recommended)
|
|
9
|
+
bunx mcpmon
|
|
10
|
+
|
|
11
|
+
# Or install globally
|
|
12
|
+
bun install -g mcpmon
|
|
13
|
+
|
|
14
|
+
# Python alternative
|
|
15
|
+
pip install mcpmon
|
|
16
|
+
|
|
17
|
+
# Or download binary from GitHub releases (no dependencies)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
mcpmon --watch src/ -- python -m my_mcp_server
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Options
|
|
27
|
+
|
|
28
|
+
- `--watch, -w` - Directory to watch (default: current directory)
|
|
29
|
+
- `--ext, -e` - File extensions to watch, comma-separated (default: py)
|
|
30
|
+
|
|
31
|
+
### Examples
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Watch current directory for .py changes
|
|
35
|
+
mcpmon -- python server.py
|
|
36
|
+
|
|
37
|
+
# Watch src/ for .py and .json changes
|
|
38
|
+
mcpmon --watch src/ --ext py,json -- python -m myserver
|
|
39
|
+
|
|
40
|
+
# With crucible-mcp
|
|
41
|
+
mcpmon --watch src/crucible/ -- crucible-mcp
|
|
42
|
+
|
|
43
|
+
# With sage-mcp
|
|
44
|
+
mcpmon --watch ~/.sage/ --ext py,yaml -- sage-mcp
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## MCP Config
|
|
48
|
+
|
|
49
|
+
Use mcpmon in your `.mcp.json` for hot reload during development:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"mcpServers": {
|
|
54
|
+
"my-server": {
|
|
55
|
+
"command": "mcpmon",
|
|
56
|
+
"args": ["--watch", "src/", "--", "python", "-m", "my_server"]
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## How it works
|
|
63
|
+
|
|
64
|
+
1. Starts your MCP server as a subprocess
|
|
65
|
+
2. Watches specified directory for file changes
|
|
66
|
+
3. On change: SIGTERM → wait 2s → SIGKILL → restart
|
|
67
|
+
4. Claude Code automatically reconnects to the restarted server
|
package/mcpmon.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""mcpmon: Hot reload for MCP servers. Like nodemon, but for MCP."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import subprocess
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from watchfiles import watch, Change
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_args():
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
description="Hot reload wrapper for MCP servers",
|
|
16
|
+
usage="mcpmon --watch <dir> [--ext <ext>] -- <command>",
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"--watch", "-w",
|
|
20
|
+
type=str,
|
|
21
|
+
default=".",
|
|
22
|
+
help="Directory to watch (default: current directory)",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--ext", "-e",
|
|
26
|
+
type=str,
|
|
27
|
+
default="py",
|
|
28
|
+
help="File extensions to watch, comma-separated (default: py)",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"command",
|
|
32
|
+
nargs=argparse.REMAINDER,
|
|
33
|
+
help="Command to run (after --)",
|
|
34
|
+
)
|
|
35
|
+
args = parser.parse_args()
|
|
36
|
+
|
|
37
|
+
# Remove leading -- from command if present
|
|
38
|
+
if args.command and args.command[0] == "--":
|
|
39
|
+
args.command = args.command[1:]
|
|
40
|
+
|
|
41
|
+
if not args.command:
|
|
42
|
+
parser.error("No command specified. Use: mcpmon --watch src/ -- <command>")
|
|
43
|
+
|
|
44
|
+
return args
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def terminate_process(proc: subprocess.Popen) -> None:
|
|
48
|
+
"""Gracefully terminate process: SIGTERM, wait 2s, SIGKILL."""
|
|
49
|
+
if proc.poll() is not None:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
proc.terminate()
|
|
53
|
+
try:
|
|
54
|
+
proc.wait(timeout=2)
|
|
55
|
+
except subprocess.TimeoutExpired:
|
|
56
|
+
proc.kill()
|
|
57
|
+
proc.wait()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def start_process(command: list[str]) -> subprocess.Popen:
|
|
61
|
+
"""Start the MCP server process."""
|
|
62
|
+
return subprocess.Popen(command)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def should_reload(changes: set, extensions: set[str]) -> bool:
|
|
66
|
+
"""Check if any changed file matches our extensions."""
|
|
67
|
+
for change_type, path in changes:
|
|
68
|
+
if change_type in (Change.added, Change.modified):
|
|
69
|
+
if Path(path).suffix.lstrip(".") in extensions:
|
|
70
|
+
return True
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main():
|
|
75
|
+
args = parse_args()
|
|
76
|
+
|
|
77
|
+
watch_path = Path(args.watch).resolve()
|
|
78
|
+
extensions = {ext.strip().lstrip(".") for ext in args.ext.split(",")}
|
|
79
|
+
command = args.command
|
|
80
|
+
|
|
81
|
+
print(f"[mcpmon] Watching {watch_path} for .{', .'.join(extensions)} changes")
|
|
82
|
+
print(f"[mcpmon] Running: {' '.join(command)}")
|
|
83
|
+
|
|
84
|
+
proc = start_process(command)
|
|
85
|
+
|
|
86
|
+
# Handle Ctrl+C gracefully
|
|
87
|
+
def signal_handler(signum, frame):
|
|
88
|
+
print("\n[mcpmon] Shutting down...")
|
|
89
|
+
terminate_process(proc)
|
|
90
|
+
sys.exit(0)
|
|
91
|
+
|
|
92
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
93
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
for changes in watch(watch_path):
|
|
97
|
+
if should_reload(changes, extensions):
|
|
98
|
+
changed_files = [p for _, p in changes]
|
|
99
|
+
print(f"[mcpmon] Change detected: {', '.join(changed_files)}")
|
|
100
|
+
print("[mcpmon] Restarting...")
|
|
101
|
+
|
|
102
|
+
terminate_process(proc)
|
|
103
|
+
proc = start_process(command)
|
|
104
|
+
|
|
105
|
+
print("[mcpmon] Server restarted")
|
|
106
|
+
except KeyboardInterrupt:
|
|
107
|
+
pass
|
|
108
|
+
finally:
|
|
109
|
+
terminate_process(proc)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
main()
|
package/mcpmon.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* mcpmon: Hot reload for MCP servers. Like nodemon, but for MCP.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { watch } from "fs";
|
|
7
|
+
import { spawn, type Subprocess } from "bun";
|
|
8
|
+
import { parseArgs } from "util";
|
|
9
|
+
|
|
10
|
+
const { values, positionals } = parseArgs({
|
|
11
|
+
args: Bun.argv.slice(2),
|
|
12
|
+
options: {
|
|
13
|
+
watch: { type: "string", short: "w", default: "." },
|
|
14
|
+
ext: { type: "string", short: "e", default: "py" },
|
|
15
|
+
help: { type: "boolean", short: "h", default: false },
|
|
16
|
+
},
|
|
17
|
+
allowPositionals: true,
|
|
18
|
+
strict: false,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
if (values.help || positionals.length === 0) {
|
|
22
|
+
console.log(`mcpmon - Hot reload for MCP servers
|
|
23
|
+
|
|
24
|
+
Usage: mcpmon [options] -- <command>
|
|
25
|
+
|
|
26
|
+
Options:
|
|
27
|
+
-w, --watch <dir> Directory to watch (default: .)
|
|
28
|
+
-e, --ext <exts> Extensions to watch, comma-separated (default: py)
|
|
29
|
+
-h, --help Show this help
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
mcpmon -- python server.py
|
|
33
|
+
mcpmon --watch src/ --ext py,json -- python -m myserver
|
|
34
|
+
mcpmon --watch src/crucible/ -- crucible-mcp
|
|
35
|
+
`);
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Remove leading "--" if present
|
|
40
|
+
const command = positionals[0] === "--" ? positionals.slice(1) : positionals;
|
|
41
|
+
|
|
42
|
+
if (command.length === 0) {
|
|
43
|
+
console.error("[mcpmon] Error: No command specified");
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const watchDir = values.watch as string;
|
|
48
|
+
const extensions = new Set((values.ext as string).split(",").map(e => e.trim().replace(/^\./, "")));
|
|
49
|
+
|
|
50
|
+
let proc: Subprocess | null = null;
|
|
51
|
+
|
|
52
|
+
function startServer(): void {
|
|
53
|
+
console.log(`[mcpmon] Starting: ${command.join(" ")}`);
|
|
54
|
+
proc = spawn({
|
|
55
|
+
cmd: command,
|
|
56
|
+
stdout: "inherit",
|
|
57
|
+
stderr: "inherit",
|
|
58
|
+
stdin: "inherit",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function stopServer(): Promise<void> {
|
|
63
|
+
if (!proc || proc.exitCode !== null) return;
|
|
64
|
+
|
|
65
|
+
proc.kill("SIGTERM");
|
|
66
|
+
|
|
67
|
+
// Wait up to 2 seconds for graceful shutdown
|
|
68
|
+
const timeout = setTimeout(() => {
|
|
69
|
+
if (proc && proc.exitCode === null) {
|
|
70
|
+
proc.kill("SIGKILL");
|
|
71
|
+
}
|
|
72
|
+
}, 2000);
|
|
73
|
+
|
|
74
|
+
await proc.exited;
|
|
75
|
+
clearTimeout(timeout);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function restartServer(): Promise<void> {
|
|
79
|
+
await stopServer();
|
|
80
|
+
startServer();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function shouldReload(filename: string | null): boolean {
|
|
84
|
+
if (!filename) return false;
|
|
85
|
+
const ext = filename.split(".").pop() || "";
|
|
86
|
+
return extensions.has(ext);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Start server
|
|
90
|
+
console.log(`[mcpmon] Watching ${watchDir} for .${[...extensions].join(", .")} changes`);
|
|
91
|
+
startServer();
|
|
92
|
+
|
|
93
|
+
// Watch for changes
|
|
94
|
+
const watcher = watch(watchDir, { recursive: true }, async (event, filename) => {
|
|
95
|
+
if (event === "change" && shouldReload(filename)) {
|
|
96
|
+
console.log(`[mcpmon] ${filename} changed, restarting...`);
|
|
97
|
+
await restartServer();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Handle shutdown
|
|
102
|
+
process.on("SIGINT", async () => {
|
|
103
|
+
console.log("\n[mcpmon] Shutting down...");
|
|
104
|
+
watcher.close();
|
|
105
|
+
await stopServer();
|
|
106
|
+
process.exit(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
process.on("SIGTERM", async () => {
|
|
110
|
+
watcher.close();
|
|
111
|
+
await stopServer();
|
|
112
|
+
process.exit(0);
|
|
113
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mcpmon",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Hot reload for MCP servers. Like nodemon, but for MCP.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "mcpmon.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mcpmon": "mcpmon.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "bun build --compile mcpmon.ts --outfile dist/mcpmon"
|
|
12
|
+
},
|
|
13
|
+
"keywords": ["mcp", "hot-reload", "nodemon", "bun", "development"],
|
|
14
|
+
"author": "be.nvy",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/b17z/mcpmon"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"bun": ">=1.0.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/pyproject.toml
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mcpmon"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Hot reload for MCP servers. Like nodemon, but for MCP."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "be.nvy" }]
|
|
13
|
+
keywords = ["mcp", "hot-reload", "development", "nodemon"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"watchfiles>=0.20.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
mcpmon = "mcpmon:main"
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/b17z/mcpmon"
|