244 lines
10 KiB
Bash
Executable File
244 lines
10 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# MCP servers, registered with the agent runtimes on this machine.
|
|
#
|
|
# An MCP server is a URL plus a bearer token (or, for a stdio server, a launcher
|
|
# script plus a token), and the token is the whole reason
|
|
# this is a stage rather than a manifest line. Panama is a public repository, so
|
|
# the tokens cannot live in it, and neither runtime keeps its server list in a
|
|
# file that could be symlinked anyway: Codex writes them into config.toml beside
|
|
# dozens of unrelated settings, and Claude Code into ~/.claude.json. There is no
|
|
# file to point at, so this registers them through the runtime's own CLI.
|
|
#
|
|
# What is tracked is user/agents/mcp/servers, which names each server and which
|
|
# variable carries its token. What is not tracked is user/agents/mcp/env, which
|
|
# holds the tokens. A new machine gets the servers by dropping its own env file
|
|
# beside the tracked one and re-running ./install.
|
|
#
|
|
# This is personal content, so it obeys the same interview decision link-user
|
|
# does. A machine that never said yes registers nothing.
|
|
#
|
|
# Claude Code and opencode are handled. Codex stores its servers inside
|
|
# config.toml, and rewriting a section of somebody's live TOML is a worse
|
|
# failure mode than leaving two lines for them to paste once.
|
|
#
|
|
# opencode has no CLI for this either, but its config is plain JSON, so this
|
|
# stage writes it whole from user/agents/opencode/config.json -- the tracked
|
|
# template carrying everything that is not a secret -- plus the MCP block built
|
|
# here from the server rows. The whole file is generated rather than merged into
|
|
# because the point of the template is that every machine ends up with the same
|
|
# opencode experience, and a merged file drifts the moment one machine is edited
|
|
# by hand. The output says so at the top, and anything worth changing goes in
|
|
# the template, where it reaches the other machines too.
|
|
|
|
set -euo pipefail
|
|
|
|
log() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
|
|
warn() { echo -e "\033[1;33m[WARN]\033[0m $*" >&2; }
|
|
|
|
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
|
MCP_DIR="$PANAMA_PATH/user/agents/mcp"
|
|
SERVERS="$MCP_DIR/servers"
|
|
ENV_FILE="$MCP_DIR/env"
|
|
TEMPLATE="$PANAMA_PATH/user/agents/opencode/config.json"
|
|
PANAMA_OLD="$PANAMA_PATH/config/old"
|
|
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama"
|
|
DECISION="$STATE_DIR/user-content"
|
|
|
|
[[ -r "$SERVERS" ]] || { log "No MCP server list; nothing to register."; exit 0; }
|
|
|
|
# The same gate link-user uses, read the same way, so one answer governs all
|
|
# personal content rather than two stages disagreeing about it.
|
|
decision="$([[ -r "$DECISION" ]] && cat "$DECISION" || printf 'no')"
|
|
if [[ "$decision" != "yes" ]]; then
|
|
log "Personal content is not enabled on this machine; no MCP servers registered."
|
|
exit 0
|
|
fi
|
|
|
|
has_claude=false
|
|
has_opencode=false
|
|
command -v claude >/dev/null 2>&1 && has_claude=true
|
|
command -v opencode >/dev/null 2>&1 && has_opencode=true
|
|
if ! $has_claude && ! $has_opencode; then
|
|
log "Neither Claude Code nor opencode is installed; nothing to register."
|
|
exit 0
|
|
fi
|
|
|
|
# Tokens are optional. A machine without the env file still registers any server
|
|
# that needs no header, and says which ones it skipped rather than failing.
|
|
if [[ -r "$ENV_FILE" ]]; then
|
|
set -a
|
|
# shellcheck source=/dev/null
|
|
. "$ENV_FILE"
|
|
set +a
|
|
else
|
|
warn "No $ENV_FILE; servers needing a token will be skipped."
|
|
fi
|
|
|
|
registered=0
|
|
skipped=0
|
|
# The opencode mcp object, built up one server at a time.
|
|
opencode_mcp='{}'
|
|
|
|
while read -r name transport target token_var _rest; do
|
|
case "${name:-}" in ''|'#'*) continue ;; esac
|
|
if [[ -z "${transport:-}" || -z "${target:-}" ]]; then
|
|
warn "Ignoring malformed row for '$name'."
|
|
continue
|
|
fi
|
|
|
|
token=""
|
|
if [[ -n "${token_var:-}" ]]; then
|
|
token="${!token_var:-}"
|
|
if [[ -z "$token" ]]; then
|
|
warn "Skipping $name: $token_var is not set in $ENV_FILE."
|
|
skipped=$((skipped + 1))
|
|
continue
|
|
fi
|
|
fi
|
|
|
|
# An http server's token normally travels as an Authorization header. One
|
|
# that only reads it from the query string (Penpot's userToken) marks the
|
|
# spot with {TOKEN} in its URL instead, and gets no header. A stdio server's
|
|
# target is a launcher under user/agents/mcp/, run from this checkout so
|
|
# every machine runs the same script, and its token arrives as MCP_TOKEN.
|
|
url="$target"
|
|
args=()
|
|
if [[ "$transport" == stdio ]]; then
|
|
launcher="$MCP_DIR/$target"
|
|
if [[ ! -x "$launcher" ]]; then
|
|
warn "Skipping $name: $launcher is not an executable launcher."
|
|
skipped=$((skipped + 1))
|
|
continue
|
|
fi
|
|
[[ -n "$token" ]] && args=(-e "MCP_TOKEN=$token")
|
|
elif [[ -n "$token" ]]; then
|
|
if [[ "$url" == *'{TOKEN}'* ]]; then
|
|
url="${url//\{TOKEN\}/$token}"
|
|
else
|
|
args=(-H "Authorization: $token")
|
|
fi
|
|
fi
|
|
|
|
auth=""
|
|
[[ "${args[0]:-}" == -H ]] && auth="${args[1]#Authorization: }"
|
|
|
|
if [[ "$transport" == stdio ]]; then
|
|
entry="$(jq -cn --arg c "$launcher" --arg t "$token" \
|
|
'{type: "local", command: [$c]} + (if $t == "" then {} else {environment: {MCP_TOKEN: $t}} end)')"
|
|
else
|
|
entry="$(jq -cn --arg u "$url" --arg a "$auth" \
|
|
'{type: "remote", url: $u} + (if $a == "" then {} else {headers: {Authorization: $a}} end)')"
|
|
fi
|
|
opencode_mcp="$(jq -c --arg n "$name" --argjson e "$entry" '.[$n] = $e' <<<"$opencode_mcp")"
|
|
|
|
$has_claude || continue
|
|
|
|
# A server already registered exactly as this row would register it is left
|
|
# alone. Removing and re-adding it would throw away the OAuth session of a
|
|
# server that signs in on its own (bill-tracker, atlassian), so every
|
|
# upgrade would sign them out again.
|
|
if [[ "$transport" == stdio ]]; then
|
|
want="$(jq -cn --arg c "$launcher" --arg t "$token" \
|
|
'{command: $c, env: (if $t == "" then null else {MCP_TOKEN: $t} end)}')"
|
|
have="$(jq -c --arg n "$name" \
|
|
'.mcpServers[$n] // {} | {command, env: (if (.env // {}) == {} then null else .env end)}' \
|
|
"$HOME/.claude.json" 2>/dev/null || true)"
|
|
else
|
|
want="$(jq -cn --arg u "$url" --arg a "$auth" \
|
|
'{url: $u, auth: (if $a == "" then null else $a end)}')"
|
|
have="$(jq -c --arg n "$name" \
|
|
'.mcpServers[$n] // {} | {url, auth: (.headers.Authorization // null)}' \
|
|
"$HOME/.claude.json" 2>/dev/null || true)"
|
|
fi
|
|
if [[ -n "$have" && "$have" == "$want" ]]; then
|
|
log "Already registered $name."
|
|
registered=$((registered + 1))
|
|
continue
|
|
fi
|
|
|
|
# Re-registering is how a new server or a rotated token reaches the
|
|
# runtime. Removing first avoids the CLI refusing a name it already knows.
|
|
# Neither call may print the token, so both are quiet unless they fail.
|
|
claude mcp remove "$name" -s user >/dev/null 2>&1 || true
|
|
if [[ "$transport" == stdio ]]; then
|
|
add=(claude mcp add "$name" -s user "${args[@]}" -- "$launcher")
|
|
else
|
|
add=(claude mcp add --transport "$transport" "$name" "$url" "${args[@]}" -s user)
|
|
fi
|
|
if "${add[@]}" >/dev/null 2>&1; then
|
|
log "Registered $name."
|
|
registered=$((registered + 1))
|
|
else
|
|
warn "Could not register $name."
|
|
skipped=$((skipped + 1))
|
|
fi
|
|
done <"$SERVERS"
|
|
|
|
if $has_claude; then
|
|
log "MCP servers: $registered registered, $skipped skipped."
|
|
log "Claude Code loads them at start, so restart a running session to pick them up."
|
|
fi
|
|
|
|
$has_opencode || exit 0
|
|
|
|
# opencode reads opencode.jsonc. The file holds tokens once this runs, so it is
|
|
# written private and swapped in whole, the way ~/.claude.json already holds
|
|
# them for Claude Code.
|
|
OPENCODE_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opencode"
|
|
opencode_config="$OPENCODE_DIR/opencode.jsonc"
|
|
|
|
[[ -r "$TEMPLATE" ]] || { warn "No $TEMPLATE; opencode config not written."; exit 0; }
|
|
if ! jq -e . "$TEMPLATE" >/dev/null 2>&1; then
|
|
warn "$TEMPLATE is not valid JSON; opencode config not written."
|
|
exit 0
|
|
fi
|
|
|
|
# Nothing at the destination is destroyed, the promise link-user and link-skills
|
|
# make. A file this stage did not write is somebody's own work and goes to
|
|
# config/old/ under a name that says where it came from. One carrying the marker
|
|
# was written by an earlier run and is replaced in place, so re-running never
|
|
# buries its own output and config/old/ does not grow on every upgrade.
|
|
MARKER='generated by setup/scripts/link-mcp'
|
|
displace() {
|
|
local destination="$1" backup
|
|
[[ -e "$destination" || -L "$destination" ]] || return 0
|
|
if [[ -L "$destination" ]]; then
|
|
rm -f "$destination"
|
|
return 0
|
|
fi
|
|
grep -qF "$MARKER" "$destination" 2>/dev/null && return 0
|
|
backup="$PANAMA_OLD/mcp-$(printf '%s' "${destination#"$HOME"/}" | tr '/' '-')"
|
|
[[ -e "$backup" ]] && backup="$backup.$(date +%s)"
|
|
mv "$destination" "$backup"
|
|
log "Moved existing $destination to $backup"
|
|
}
|
|
|
|
mkdir -p "$OPENCODE_DIR" "$PANAMA_OLD"
|
|
# Two names for one file would leave opencode choosing between them, and the one
|
|
# this stage does not write would win or lose on discovery order.
|
|
displace "${opencode_config%.jsonc}.json"
|
|
displace "$opencode_config"
|
|
|
|
# The template is portable prose: it names the rules directory as ~/ so it does
|
|
# not carry this machine's login. opencode resolves a leading ~/ on its own, but
|
|
# expanding it here keeps the generated file readable the way a person would
|
|
# have written it by hand when they went looking for where the path came from.
|
|
merged="$(jq --argjson mcp "$opencode_mcp" --arg home "$HOME" \
|
|
'.mcp = $mcp
|
|
| .instructions = [(.instructions // [])[]
|
|
| if startswith("~/") then $home + .[1:] else . end]' \
|
|
<<<"$(cat "$TEMPLATE")")"
|
|
|
|
tmp="$(mktemp "$OPENCODE_DIR/.opencode-config.XXXXXX")"
|
|
{
|
|
printf '// %s\n' "$MARKER"
|
|
printf '// Edit user/agents/opencode/config.json in the Panama checkout instead.\n'
|
|
printf '// This file is replaced whenever ./install or panama update runs.\n'
|
|
printf '%s\n' "$merged"
|
|
} >"$tmp"
|
|
chmod 600 "$tmp"
|
|
mv "$tmp" "$opencode_config"
|
|
|
|
log "opencode: $(jq -r 'length' <<<"$opencode_mcp") MCP servers and the template written to $opencode_config."
|