From eb0056ac94d937c8624bfd53e30209d32299f6c4 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Thu, 1 Oct 2026 17:33:12 -0400 Subject: [PATCH] Register the self-hosted Sentry MCP server and keep OAuth sign-ins across updates link-mcp gains a stdio transport: the servers row names a launcher under user/agents/mcp/, run from the checkout so every machine runs the same script, and the token reaches it as MCP_TOKEN. sentry-gbrown is the first, pointing Sentry's MCP server at sentry.gbrown.org with a read-only token from the ignored env file. A server already registered exactly as its row would register it is now left alone. Re-adding it every run threw away the sign-in of the OAuth servers, bill-tracker and atlassian, on every update. --- setup/scripts/link-mcp | 72 +++++++++++++++++++++++++------ user/agents/mcp/bin/sentry-gbrown | 12 ++++++ user/agents/mcp/servers | 10 ++++- 3 files changed, 79 insertions(+), 15 deletions(-) create mode 100755 user/agents/mcp/bin/sentry-gbrown diff --git a/setup/scripts/link-mcp b/setup/scripts/link-mcp index 7084048..711528c 100755 --- a/setup/scripts/link-mcp +++ b/setup/scripts/link-mcp @@ -2,7 +2,8 @@ # MCP servers, registered with the agent runtimes on this machine. # -# An MCP server is a URL plus a bearer token, and the token is the whole reason +# 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 @@ -62,17 +63,14 @@ fi registered=0 skipped=0 -while read -r name transport url token_var _rest; do +while read -r name transport target token_var _rest; do case "${name:-}" in ''|'#'*) continue ;; esac - if [[ -z "${transport:-}" || -z "${url:-}" ]]; then + if [[ -z "${transport:-}" || -z "${target:-}" ]]; then warn "Ignoring malformed row for '$name'." continue fi - # The token normally travels as an Authorization header. A server that only - # reads it from the query string (Penpot's userToken) marks the spot with - # {TOKEN} in its URL instead, and gets no header. - header=() + token="" if [[ -n "${token_var:-}" ]]; then token="${!token_var:-}" if [[ -z "$token" ]]; then @@ -80,20 +78,66 @@ while read -r name transport url token_var _rest; do 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 - header=(-H "Authorization: $token") + args=(-H "Authorization: $token") fi fi - # Re-registering is how this stays idempotent across upgrades, and how 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. + # 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 + auth="" + [[ "${args[0]:-}" == -H ]] && auth="${args[1]#Authorization: }" + 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 claude mcp add --transport "$transport" "$name" "$url" "${header[@]}" \ - -s user >/dev/null 2>&1; then + 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 diff --git a/user/agents/mcp/bin/sentry-gbrown b/user/agents/mcp/bin/sentry-gbrown new file mode 100755 index 0000000..3e78479 --- /dev/null +++ b/user/agents/mcp/bin/sentry-gbrown @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Sentry's MCP server, pointed at the self-hosted sentry.gbrown.org. +# +# link-mcp registers this file as a stdio server and hands it the token as +# MCP_TOKEN, read from SENTRY_GBROWN_TOKEN in user/agents/mcp/env. The token is +# a read-only personal token (Sentry > User settings > Personal Tokens, scopes +# alerts/event/org/project/team:read). The hosted sentry.io server cannot reach +# a self-hosted instance, which is why this one runs locally. +set -euo pipefail +: "${MCP_TOKEN:?MCP_TOKEN is not set; register this server through link-mcp}" +export SENTRY_ACCESS_TOKEN="$MCP_TOKEN" +exec npx -y @sentry/mcp-server@latest --host=sentry.gbrown.org --organization-slug=sentry diff --git a/user/agents/mcp/servers b/user/agents/mcp/servers index 169ee52..693519a 100644 --- a/user/agents/mcp/servers +++ b/user/agents/mcp/servers @@ -1,6 +1,9 @@ # MCP servers registered with the local agent runtimes. # -# +# +# +# transport is http (third column is a URL) or stdio (third column is a launcher +# under user/agents/mcp/, which receives the token as MCP_TOKEN). # # The token variable names an entry in user/agents/mcp/env, which is ignored by # git. This file carries no secret, so it is tracked like the rest of user/ and @@ -29,3 +32,8 @@ atlassian http https://mcp.atlassian.com/v1/mcp # key from Penpot's Account > Integrations, and Penpot only reads it from the # query string, hence {TOKEN}. The same key connects the in-file MCP plugin. penpot http https://penpot.gbrown.org/mcp/stream?userToken={TOKEN} PENPOT_MCP_TOKEN + +# Sentry for the self-hosted sentry.gbrown.org. A stdio server: the third column +# is a launcher under user/agents/mcp/, and the token reaches it as MCP_TOKEN. +# The hosted mcp.sentry.dev only speaks to sentry.io. +sentry-gbrown stdio bin/sentry-gbrown SENTRY_GBROWN_TOKEN