TLDR;
Keep MCP servers configuration between Claude Code and Kilo Code in sync using Claude Code Hooks and launchd (primary process management system on macOS)

Pain Point
Each AI coding tool stores its global MCP server configuration in a different location with a somewhat different format.
Real scenario would look like:
- Add it to Claude Code’s config
- Notice that you haven’t installed the MCP in Kilo while coding with Kilo
- Install
- Resume coding
Challenge
Let’s look at what we’re dealing with:
Claude Code
- Stores everything in ~/.claude.json
- This file is MB big of settings, state, and configurations
- MCP servers are buried inside a mcpServersobject
- Changes happen when you modify settings or the tool updates itself
Kilo Code (VSCode Extension)
- Stores in VSCode’s global storage: ~/Library/Application Support/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json
- Much simpler JSON structure
- Only contains MCP configuration
- Changes when you use Kilo’s UI to add/remove servers
The Real Problem
- Different formats: One is a massive config file, the other is dedicated to MCP
- Different locations: macOS Library vs dotfiles
- No sync mechanism: They have no idea the other exists
- Manual hell: Either manual install or copy-paste every time you add a server on one tool
Solution: Claude Code Hooks & Shell Scripting with Launchd
I built a bidirectional sync system that lives in my dotfiles. Here’s the architecture:

Key Components
The system consists of four main scripts:
- extract.sh- Extracts the- mcpServerssection from Claude’s massive config file
- merge.sh- Merges MCP servers back into Claude’s config (with automatic backups)
- sync.sh- Handles bidirectional sync between dotfiles and Kilo Code
- setup.sh- Installs the launchd agent for background automation
Plus a launchd agent that watches Kilo Code’s config file for changes.
How It Works
Scenario A: Claude Code → Kilo Code
- You end a Claude Code session
- SessionEnd hook triggers (configured in ~/.claude/settings.json)
- Extract script runs: Pulls mcpServersfrom~/.claude.json→ saves toai-config-sync/config/mcp-servers.json
- Sync script runs: Copies mcp-servers.json→ Kilo Code’smcp_settings.json
- ✅ Done! Kilo Code now has your latest MCP servers
Scenario B: Kilo Code → Claude Code
- You add an MCP server in Kilo Code’s UI
- Kilo saves to mcp_settings.json
- Launchd detects the file change (watches the file 24/7)
- Sync script runs:
- Copies Kilo’s mcp_settings.json→ai-config-sync/config/mcp-servers.json
- Runs merge script to update ~/.claude.json
 
- Copies Kilo’s 
- ✅ Done! Claude Code now has your new server
Visual Flow
# Claude → Kilo (on session end)
~/.claude.json → [extract] → ai-config-sync/config/mcp-servers.json → [sync] → Kilo
# Kilo → Claude (on file change)
Kilo → [sync] → ai-config-sync/config/mcp-servers.json → [merge] → ~/.claude.jsonDirectory Structure
The final structure looks like this:
~/.dotfiles/ai-config-sync/
├── config/
│   └── mcp-servers.json        # Source of truth
├── scripts/
│   ├── extract.sh              # Extract from ~/.claude.json
│   ├── merge.sh                # Merge back to ~/.claude.json
│   ├── sync.sh                 # Bidirectional sync
│   └── setup.sh                # Install launchd agent
├── launchd/
│   └── com.user.mcp-sync.plist # macOS background agent
├── logs/                       # Runtime logs
│   ├── sync.log
│   └── error.log
└── backups/                    # Config backups
    └── claude-*.jsonImplementation Details
Why Launchd over Fswatch?
I initially considered fswatch, but launchd seemed like a better choice:
| Feature | launchd | fswatch | 
|---|---|---|
| Resource usage | ~2-5 MB (kernel-level) | ~10-20 MB (user process) | 
| Auto-start | ✅ Yes (on login) | ❌ Need to configure | 
| Survives reboot | ✅ Yes | ❌ No | 
| Terminal required | ✅ No | ❌ Yes (or use nohup) | 
| Native to macOS | ✅ Yes | ❌ Requires Homebrew | 
⚙️ Understanding launchd vs cron vs systemd
- launchd (macOS): Modern, event-driven, can watch files/directories
- cron (Unix): Time-based only, can’t watch files
- systemd (Linux): Similar to launchd, path units can watch files
For this use case, launchd’s
WatchPathsfeature is perfect—it triggers immediately when Kilo’s config changes.
The launchd plist is simple:
<key>WatchPaths</key>
<array>
    <string>/Users/ryuton/Library/Application Support/.../mcp_settings.json</string>
</array>
<key>ProgramArguments</key>
<array>
    <string>/Users/ryuton/.dotfiles/ai-config-sync/scripts/sync.sh</string>
    <string>--to-claude</string>
</array>
<key>ThrottleInterval</key>
<integer>5</integer>The ThrottleInterval prevents rapid-fire syncs if the file changes multiple times quickly.
JSON Validation
Before any sync operation, we validate JSON using jq:
if ! jq empty "$CLAUDE_CONFIG" 2>/dev/null; then
    log_error "Invalid JSON in Claude config"
    notify "MCP Sync Failed" "Invalid JSON in Claude config"
    exit 1
fiThis prevents corrupting configs with malformed JSON.
Backup Strategy
Every time we merge MCP servers back into ~/.claude.json, we create a timestamped backup:
BACKUP_FILE="$BACKUP_DIR/claude-$(date +%Y%m%d-%H%M%S).json"
cp "$CLAUDE_MAIN_CONFIG" "$BACKUP_FILE"Backups are saved in ai-config-sync/backups/ (gitignored). If something goes wrong, recovery is one command away:
cp ~/.dotfiles/ai-config-sync/backups/claude-20251005-195038.json ~/.claude.jsonNotifications
Cherry on top, the sync script sends macOS notifications so you know when syncs happen:
notify() {
    local title="$1"
    local message="$2"
    osascript -e "display notification \"$message\" with title \"$title\" sound name \"Glass\"" 2>/dev/null
}Setting It Up
1. Copy The Scripts to Your dotfiles
# Clone the structure
mkdir -p ~/.dotfiles/ai-config-sync/{config,scripts,launchd,logs,backups}
# Add the scripts (see GitHub repo for full code)2. Run The Setup script
~/.dotfiles/ai-config-sync/scripts/setup.shThis will:
- Copy the launchd plist to ~/Library/LaunchAgents/
- Load the agent (starts watching Kilo’s config)
- Create necessary directories
3. Configure Claude Code’s SessionEnd hook
Add to ~/.claude/settings.json:
{
  "hooks": {
    "SessionEnd": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "$HOME/.dotfiles/ai-config-sync/scripts/extract.sh && $HOME/.dotfiles/ai-config-sync/scripts/sync.sh --to-kilo"
          }
        ]
      }
    ]
  }
}4. Add A Shell alias (optional but handy)
In your .zshrc
alias sync-mcp="$HOME/.dotfiles/ai-config-sync/scripts/sync.sh"