link/claude.sh (view raw)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 |
#!/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
# name<TAB>spec, 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
|