A language-agnostic guide for implementing hot-reloadable MCP (Model Context Protocol) servers that update tools without process restarts.
When building MCP servers for AI agents (Claude, GPT, etc.), code changes typically require:
- Stopping the MCP server
- Restarting the AI agent/IDE
- Reconnecting everything
This breaks flow and wastes time during development.
Core insight: Decouple the MCP server from your codebase by having tools execute via CLI subprocess calls instead of direct function imports.
┌─────────────────────────────────────────────────────────────────┐
│ AI Agent (Claude, etc.) │
│ │
│ Calls tools ─────────────────────────────────────────────┐ │
│ Receives tools/list_changed notification ◄───────────┐ │ │
│ Re-fetches tool list automatically │ │ │
└───────────────────────────────────────────────────────┼───┼─────┘
│ │
STDIO / SSE / WebSocket │ │
│ │
┌───────────────────────────────────────────────────────┼───┼─────┐
│ MCP Server Process │ │ │
│ │ │ │
│ ┌─────────────────┐ ┌──────────────────────┐ │ │ │
│ │ File Watcher │───▶│ On Change: │ │ │ │
│ │ │ │ 1. Fetch new schema │─────┘ │ │
│ │ Watches: │ │ 2. Update tools │ │ │
│ │ src/**/* │ │ 3. Notify client │─────────┘ │
│ └─────────────────┘ └──────────────────────┘ │
│ │
│ Tool execution: subprocess.run(["your-cli", "command"]) │
│ Schema fetch: subprocess.run(["your-cli", "schema"]) │
└─────────────────────────────────────────────────────────────────┘
│
│ subprocess (fresh process)
▼
┌─────────────────────────────────────────────────────────────────┐
│ Your CLI / Codebase │
│ │
│ your-cli schema → Returns tool definitions as JSON │
│ your-cli command1 → Executes tool logic, returns JSON │
│ your-cli command2 → Executes tool logic, returns JSON │
│ │
│ ✓ Each call is a fresh process (no module caching) │
│ ✓ Code changes take effect immediately │
└─────────────────────────────────────────────────────────────────┘
| Approach | Problem |
|---|---|
| Direct imports | Module/class caching - changes not seen |
| Hot-reload libraries | Complex, fragile, language-specific |
| Process restart | Disrupts AI agent connection |
| CLI subprocess | ✓ Always fresh code, universal |
Each subprocess is a fresh process with:
- No cached modules/classes from previous runs
- Complete isolation from MCP server state
- Works with any language (Python, Node, Go, Rust, etc.)
Your CLI should output tool definitions as JSON:
your-cli schema --jsonOutput format:
{
"tools": [
{
"name": "tool_name",
"description": "What the tool does",
"parameters": {
"type": "object",
"properties": {
"param1": {"type": "string", "description": "..."},
"param2": {"type": "integer", "description": "..."}
},
"required": ["param1"]
},
"cli_command": ["subcommand", "--flag", "{param1}"]
}
]
}The cli_command field maps MCP tool calls to CLI invocations.
Each tool should be a CLI command that:
- Accepts arguments
- Returns JSON to stdout
- Exits with appropriate code
your-cli do-something --param1 "value" --json
# Output: {"result": "success", "data": {...}}Your MCP server needs three components:
function getSchema():
result = subprocess.run(["your-cli", "schema", "--json"])
return JSON.parse(result.stdout)
function registerTools(schema):
for each tool in schema.tools:
handler = createHandler(tool.cli_command)
mcp.registerTool(tool.name, tool.description, handler)
function createHandler(cliCommand):
return async function(params):
args = buildArgs(cliCommand, params)
result = subprocess.run(["your-cli"] + args + ["--json"])
return JSON.parse(result.stdout)
function watchForChanges(path, onNotify):
watcher.watch(path, "**/*.{py,ts,go,rs}")
watcher.on("change", async () => {
schema = getSchema() // Fresh subprocess call
registerTools(schema) // Update tool registry
onNotify() // Notify AI agent
})
The MCP protocol's tools/list_changed notification requires a session reference. Capture it on the first tool call:
session = null
function onToolCall(context):
if session == null:
session = context.session
// ... execute tool
function notifyToolsChanged():
if session != null:
session.send("notifications/tools/list_changed")
Important: The session is only available during an active request. Store it globally on first tool call.
| Change Type | Hot-Reloads | Requires Restart |
|---|---|---|
| Tool definitions (schema) | ✓ | |
| Tool implementation logic | ✓ | |
| CLI command code | ✓ | |
| MCP server code | ✓ | |
| File watcher code | ✓ |
The MCP server itself is a thin layer - most of your code lives in the CLI and hot-reloads automatically.
The MCP server should be minimal:
- Fetch schema via subprocess
- Register tools dynamically
- Execute tools via subprocess
- Watch files and notify
No business logic in the MCP server itself.
Your CLI defines everything:
- Tool names and descriptions
- Parameter schemas
- Command mappings
- Execution logic
The MCP server just translates between MCP protocol and CLI calls.
All communication uses JSON:
- Schema output
- Tool results
- Error messages
This ensures compatibility across languages and easy debugging.
If session isn't captured yet:
- Log a warning
- Skip notification
- Tools still work, just won't auto-refresh
- Add tool definition to your schema command:
{
"name": "new_tool",
"description": "Does something new",
"parameters": {...},
"cli_command": ["new-command", "{param}"]
}- Implement the CLI command:
your-cli new-command --param "value" --json-
Save the file
-
Automatic: File watcher detects change → fetches new schema → registers tool → notifies AI agent → AI agent sees new tool
No restart required!
The MCP server's stdout/stderr are used for protocol communication. Log to a file:
~/.your-app/mcp-server.log
Server starting- MCP server initializedRegistered N tools- Tools loaded from schemaFile change detected- Watcher triggeredRefreshed N tools- Schema re-fetched and tools updatedSession captured- Ready to send notificationsSent tools/list_changed- AI agent notified
"No session - cannot send notification"
- Call any tool first to capture the session
- Session is only available during tool execution
Tools not updating
- Verify CLI schema command works:
your-cli schema --json - Check file watcher is monitoring correct path
- Look for errors in log file
Subprocess errors
- Ensure CLI is in PATH or use absolute path
- Check CLI returns valid JSON
- Verify exit codes (0 = success)
- Use
subprocess.run()withcapture_output=True - For async:
asyncio.to_thread(subprocess.run, ...) - FastMCP: Use
ctx: Contextparameter for session access
- Use
child_process.execSync()orspawn() - For async:
util.promisify(exec) - MCP SDK: Access session via request context
- Use
exec.Command()withOutput() - For concurrent: goroutines with channels
- MCP SDK: Session available in handler context
- Use
std::process::Commandwithoutput() - For async:
tokio::process::Command - MCP SDK: Session in request state
The CLI subprocess pattern provides:
- True hot-reload - Code changes work instantly
- Language agnostic - Works with any CLI
- Simple architecture - Thin MCP server, rich CLI
- Reliable - No complex reload mechanisms
- Debuggable - Standard subprocess, JSON I/O
The trade-off is subprocess overhead per tool call, but for development workflows this is negligible compared to the productivity gains.