#!/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 # ${args[@]+...} rather than "${args[@]}": the Mac's bash 3.2 treats an # empty array as unset under set -u and aborts the whole stage. # # 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[@]+"${args[@]}"} -- "$launcher") else add=(claude mcp add --transport "$transport" "$name" "$url" ${args[@]+"${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."