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
|