#!/bin/bash # Claude Code and Claude Desktop: settings, user memory, MCP servers. Dry run by # default; pass `yes` to apply. set -euo pipefail # shellcheck source-path=SCRIPTDIR # shellcheck source=lib.sh source "$(dirname "$0")/lib.sh" "$@" link "$config_dir/claude/settings.json" "$HOME/.claude/settings.json" # User-level memory, loaded into every Claude Code session on this machine. What # only makes sense on some machines goes in ~/.claude/rules/, which Claude Code # loads beside it, linked there by whichever layer owns it. link "$config_dir/claude/CLAUDE.md" "$HOME/.claude/CLAUDE.md" # User-level MCP servers. This repo defines none: every *.json in # ~/.config/claude/mcp-servers.d/ maps server names to specs, and other layers # link their files in. This part is the only thing that registers them, so it # can also unregister: the names it registered last time are kept in a # manifest, and any no longer defined in the directory are removed -- from # Claude Code and Claude Desktop alike. Servers registered by any other means # are left alone. # # They cannot be linked: Claude Code only reads them from ~/.claude.json, which # it also rewrites constantly with session state, so they are pushed in with # `claude mcp add-json`. Re-adding on every run is deliberate -- it is how an # edited definition takes effect. # # Run by link.sh after other layers' link.sh, so their files are in place on a # first link. # # Two things a spec may carry beyond what Claude Code takes, both resolved here: # # "${szecret:NAME}" anywhere in a string value, replaced with `szecret get # NAME` as the server is registered with Claude Code. For a # remote server's Authorization header: Claude Code's own # headersHelper is not run in a workspace without persisted # trust, which includes $HOME, so a token fetched there # would simply be missing. The secret ends up in # ~/.claude.json, which Claude Code keeps mode 600. A # secret that cannot be read skips that server, loudly. # # "desktop" a whole spec to give Claude Desktop instead of this one, # e.g. a local stdio server where Claude Code gets a remote # one. Claude Code never sees it. mcp_dir="$xdg_config_dir/claude/mcp-servers.d" mcp_manifest="${XDG_STATE_HOME:-$HOME/.local/state}/dotfiles/claude-mcp-servers" claude_desktop_dir="$HOME/Library/Application Support/Claude" have_claude="" command -v claude >/dev/null && have_claude=y have_desktop="" [[ -d "$claude_desktop_dir" ]] && have_desktop=y # Nothing to register with: done. Checked before anything else runs, python3 # included -- on a Mac without the command line tools /usr/bin/python3 is a stub # that fails. if [[ -z "$have_claude" && -z "$have_desktop" ]] then exit 0 fi # namespec, one per server, from every file in the directory. A name # defined twice, or a file that is not a JSON object of servers, skips MCP # registration for this run -- loudly, and without touching the manifest -- but # does not stop link.sh from linking everything else. if ! mcp_wanted="$(python3 -c 'import glob, json, os, sys servers = {} for path in sorted(glob.glob(os.path.join(sys.argv[1], "*.json"))): try: defined = json.load(open(path)) except (OSError, ValueError) as e: sys.exit("cannot read %s: %s" % (path, e)) if not isinstance(defined, dict): sys.exit("%s is not an object of MCP servers" % path) for name, spec in defined.items(): if name in servers: sys.exit("MCP server %s is defined twice (again in %s)" % (name, path)) servers[name] = spec for name, spec in servers.items(): print(name, json.dumps(spec), sep="\t")' "$mcp_dir" 2>&1)" then red "MCP servers not registered: $mcp_wanted" exit 0 fi mcp_wanted_names="$(printf '%s\n' "$mcp_wanted" | cut -f1 | sed '/^$/d' | sort)" mcp_previous_names="" if [[ -r "$mcp_manifest" ]] then mcp_previous_names="$(sed '/^$/d' "$mcp_manifest" | sort)" fi # Registered last time, not wanted now. mcp_stale_names="$(comm -23 <(printf '%s\n' "$mcp_previous_names" | sed '/^$/d') \ <(printf '%s\n' "$mcp_wanted_names" | sed '/^$/d'))" if [[ -n "$have_claude" ]] then while IFS= read -r name do [[ -n "$name" ]] || continue if [[ -n "${is_dry_run}" ]] then echo "Will unregister MCP server (user scope): $name" elif claude mcp remove --scope user "$name" >/dev/null 2>&1 then yellow "MCP server (user scope) removed: $name" else yellow "MCP server (user scope) already gone: $name" fi done <<< "$mcp_stale_names" while IFS=$'\t' read -r name spec do [[ -n "$name" ]] || continue if [[ -n "${is_dry_run}" ]] then echo "Will register MCP server (user scope): $name" else if ! code_spec="$(python3 -c 'import json, re, subprocess, sys spec = json.loads(sys.argv[1]) spec.pop("desktop", None) def secret(match): name = match.group(1) try: return subprocess.run(["szecret", "get", name], check=True, capture_output=True, text=True).stdout.strip() except (OSError, subprocess.CalledProcessError): sys.exit("szecret get %s failed" % name) def expand(value): if isinstance(value, str): return re.sub(r"\$\{szecret:([A-Za-z0-9._-]+)\}", secret, value) if isinstance(value, dict): return {k: expand(v) for k, v in value.items()} if isinstance(value, list): return [expand(v) for v in value] return value print(json.dumps(expand(spec)))' "$spec" 2>&1)" then red "MCP server (user scope) not registered: $name: $code_spec" continue fi green "MCP server (user scope): $name" claude mcp remove --scope user "$name" >/dev/null 2>&1 || true claude mcp add-json --scope user "$name" "$code_spec" >/dev/null fi done <<< "$mcp_wanted" fi # The same servers for Claude Desktop, which is a separate app reading a # separate file - registering with Claude Code above does nothing for it. Not a # symlink for the same reason as ~/.claude.json: the app rewrites this file with # its own UI preferences, so only the servers this part manages are touched -- # the wanted ones set, the stale ones removed -- and the rest of the file, # including servers added some other way, is left as it is. Only where Desktop # is installed, so other machines stay clean. # # Two fixups on the way in, both because Desktop is a GUI app and so inherits # launchd's PATH (/usr/bin:/bin:/usr/sbin:/sbin) rather than a login shell's. # Claude Code needs neither, being started from a shell. # # command resolved to an absolute path, since a bare "npx" lives in # /opt/homebrew/bin and is not on that PATH. # # env.PATH set to include the command's own directory. Resolving the command # is not sufficient on its own: /opt/homebrew/bin/npx is a # "#!/usr/bin/env node" script, so spawning it with a stripped PATH # gets "env: node: No such file or directory" - the interpreter is # looked up on PATH even when the script itself was not. # # Desktop only reads the file at launch, so an edit needs a restart to take. if [[ -n "$have_desktop" ]] then desktop_status="$(DRY_RUN="$is_dry_run" MCP_WANTED="$mcp_wanted" MCP_STALE="$mcp_stale_names" python3 -c 'import json, os, shutil, sys dst = sys.argv[1] GUI_PATH = "/usr/bin:/bin:/usr/sbin:/sbin" wanted = {} missing = [] for line in os.environ["MCP_WANTED"].splitlines(): if not line: continue name, spec = line.split("\t", 1) spec = json.loads(spec) spec = spec.get("desktop", spec) command = spec.get("command") if command: if not os.path.isabs(command): found = shutil.which(command) if not found: missing.append(name + " (" + command + ")") continue command = found spec["command"] = command bindir = os.path.dirname(command) env = dict(spec.get("env") or {}) parts = [p for p in env.get("PATH", GUI_PATH).split(":") if p and p != bindir] env["PATH"] = ":".join([bindir] + parts) spec["env"] = env wanted[name] = spec stale = [n for n in os.environ["MCP_STALE"].splitlines() if n] try: with open(dst) as f: config = json.load(f) except (FileNotFoundError, ValueError): config = {} servers = dict(config.get("mcpServers") or {}) before = dict(servers) for name in stale: servers.pop(name, None) servers.update(wanted) summary = ",".join(sorted(wanted)) or "none" if stale: summary += " (removing " + ",".join(stale) + ")" if missing: summary += "; skipped, command not on PATH: " + ", ".join(missing) # Unchanged also covers a Mac with Desktop and no servers to manage: the file is # not created just to hold an empty mcpServers. if servers == before: print("unchanged:" + summary) sys.exit(0) if os.environ.get("DRY_RUN"): print("would-write:" + summary) sys.exit(0) config["mcpServers"] = servers tmp = dst + ".tmp" with open(tmp, "w") as f: json.dump(config, f, indent=2) f.write("\n") os.replace(tmp, dst) print("wrote:" + summary) ' "$claude_desktop_dir/claude_desktop_config.json")" case "${desktop_status%%:*}" in wrote) green "MCP server (Claude Desktop): ${desktop_status#*:} - restart Claude Desktop to load" ;; unchanged) green "MCP server (Claude Desktop): ${desktop_status#*:}" ;; would-write) echo "Will register MCP server (Claude Desktop): ${desktop_status#*:}" ;; esac fi # Last, so a run that fails part-way still knows next time what it has left to # remove. Removal is idempotent on both sides, so listing a server that one of # them never got is harmless. if [[ -z "${is_dry_run}" ]] then mkdir -p "$(dirname "$mcp_manifest")" printf '%s\n' "$mcp_wanted_names" | sed '/^$/d' > "$mcp_manifest" fi