mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-08 21:49:45 +08:00
Probes a remote gbrain MCP endpoint with bearer auth. POSTs initialize, classifies failures into NETWORK / AUTH / MALFORMED with one-line remediation hints, and runs a tools/list capability probe to detect sources_add MCP support (forward-compat for when gbrain ships URL ingest). Token consumed from GBRAIN_MCP_TOKEN env, never argv. Required to set both 'application/json' AND 'text/event-stream' in Accept; that gotcha costs 10 minutes of debugging when missed (regression-tested). Live-verified against wintermute (gbrain v0.27.1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
180 lines
6.9 KiB
Bash
Executable File
180 lines
6.9 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# gstack-gbrain-mcp-verify — probe a remote gbrain MCP endpoint.
|
|
#
|
|
# Usage:
|
|
# GBRAIN_MCP_TOKEN=<bearer> gstack-gbrain-mcp-verify <url>
|
|
#
|
|
# Output (always valid JSON):
|
|
# {
|
|
# "status": "success" | "network" | "auth" | "malformed",
|
|
# "server_name": "gbrain" | null,
|
|
# "server_version": "0.26.8" | null,
|
|
# "error_class": "NETWORK" | "AUTH" | "MALFORMED" | null,
|
|
# "error_text": "<remediation hint + raw>" | null,
|
|
# "sources_add_url_supported": true | false,
|
|
# "raw_initialize_body": "<full body for debugging>" | null
|
|
# }
|
|
#
|
|
# Token is consumed from the GBRAIN_MCP_TOKEN env var, never argv. Prevents
|
|
# shell-history / `ps` exposure of the bearer.
|
|
#
|
|
# Three error classes:
|
|
# NETWORK — DNS / TCP / no HTTP response
|
|
# AUTH — 401, 403, or 500 with stale-token-shaped body
|
|
# MALFORMED — 2xx but missing serverInfo, OR `Not Acceptable` (the dual
|
|
# Accept-header gotcha)
|
|
#
|
|
# `sources_add_url_supported` probes capability via tools/list — true iff the
|
|
# remote exposes `mcp__gbrain__sources_add` (gbrain hasn't shipped this as
|
|
# of v0.26.x; field is forward-compatible).
|
|
#
|
|
# Exit codes: 0 on success, 1 on classified failure, 2 on usage error.
|
|
set -euo pipefail
|
|
|
|
die_usage() {
|
|
echo "Usage: GBRAIN_MCP_TOKEN=<bearer> gstack-gbrain-mcp-verify <url>" >&2
|
|
exit 2
|
|
}
|
|
|
|
[ $# -eq 1 ] || die_usage
|
|
URL="$1"
|
|
[ -n "${GBRAIN_MCP_TOKEN:-}" ] || { echo "gstack-gbrain-mcp-verify: GBRAIN_MCP_TOKEN env var required" >&2; exit 2; }
|
|
|
|
command -v curl >/dev/null 2>&1 || { echo "gstack-gbrain-mcp-verify: curl is required" >&2; exit 2; }
|
|
command -v jq >/dev/null 2>&1 || { echo "gstack-gbrain-mcp-verify: jq is required (brew install jq)" >&2; exit 2; }
|
|
|
|
emit() {
|
|
# emit <status> <server_name> <server_version> <error_class> <error_text> <url_supported> <raw_body>
|
|
jq -n \
|
|
--arg status "$1" \
|
|
--arg server_name "${2:-}" \
|
|
--arg server_version "${3:-}" \
|
|
--arg error_class "${4:-}" \
|
|
--arg error_text "${5:-}" \
|
|
--argjson url_supported "${6:-false}" \
|
|
--arg raw "${7:-}" \
|
|
'{
|
|
status: $status,
|
|
server_name: (if $server_name == "" then null else $server_name end),
|
|
server_version: (if $server_version == "" then null else $server_version end),
|
|
error_class: (if $error_class == "" then null else $error_class end),
|
|
error_text: (if $error_text == "" then null else $error_text end),
|
|
sources_add_url_supported: $url_supported,
|
|
raw_initialize_body: (if $raw == "" then null else $raw end)
|
|
}'
|
|
}
|
|
|
|
# JSON-RPC initialize body. Both `application/json` AND `text/event-stream`
|
|
# in Accept — the MCP server returns 406 Not Acceptable without both. The
|
|
# transcript that motivated this script hit that exact failure.
|
|
INIT_BODY='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"gstack-mcp-verify","version":"1"}}}'
|
|
|
|
# Capture HTTP code + body in one pass; --max-time 10 caps total wall time.
|
|
TMPBODY=$(mktemp -t gstack-mcp-verify.XXXXXX)
|
|
trap 'rm -f "$TMPBODY"' EXIT
|
|
|
|
set +e
|
|
HTTP_CODE=$(curl -s -o "$TMPBODY" -w '%{http_code}' \
|
|
--max-time 10 \
|
|
-X POST \
|
|
-H 'Content-Type: application/json' \
|
|
-H 'Accept: application/json, text/event-stream' \
|
|
-H "Authorization: Bearer $GBRAIN_MCP_TOKEN" \
|
|
-d "$INIT_BODY" \
|
|
"$URL" 2>/dev/null)
|
|
CURL_EXIT=$?
|
|
set -e
|
|
|
|
BODY=$(cat "$TMPBODY" 2>/dev/null || echo "")
|
|
|
|
# --- NETWORK class: curl exited nonzero, no HTTP response ---
|
|
if [ "$CURL_EXIT" -ne 0 ] || [ -z "$HTTP_CODE" ] || [ "$HTTP_CODE" = "000" ]; then
|
|
HOST=$(echo "$URL" | sed -E 's|^https?://([^/:]+).*|\1|')
|
|
emit "network" "" "" "NETWORK" "check Tailscale/DNS to ${HOST} (curl exit=${CURL_EXIT})" false "$BODY"
|
|
exit 1
|
|
fi
|
|
|
|
# --- AUTH class: 401, 403, or 500 with stale-token-shaped body ---
|
|
case "$HTTP_CODE" in
|
|
401|403)
|
|
emit "auth" "" "" "AUTH" "rotate token on the brain host, re-run /setup-gbrain (HTTP $HTTP_CODE)" false "$BODY"
|
|
exit 1
|
|
;;
|
|
500)
|
|
if echo "$BODY" | grep -qiE '"(error_description|message)":[[:space:]]*"[^"]*(auth|token|unauthorized)' 2>/dev/null; then
|
|
emit "auth" "" "" "AUTH" "rotate token on the brain host, re-run /setup-gbrain (HTTP 500 stale-token shape)" false "$BODY"
|
|
exit 1
|
|
fi
|
|
;;
|
|
esac
|
|
|
|
# Anything not 2xx that isn't auth-shaped → MALFORMED with raw HTTP code.
|
|
case "$HTTP_CODE" in
|
|
2*) ;;
|
|
*)
|
|
emit "malformed" "" "" "MALFORMED" "server returned HTTP $HTTP_CODE; verify URL + version compatibility" false "$BODY"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
# --- 2xx path: body may be JSON or SSE-wrapped JSON. Strip SSE if present. ---
|
|
# MCP servers return SSE format: `event: message\ndata: {...}\n\n`. Extract
|
|
# just the JSON payload from the data: line, falling back to the body as-is.
|
|
if echo "$BODY" | head -1 | grep -q '^event:'; then
|
|
JSON_BODY=$(echo "$BODY" | sed -n 's/^data: //p' | head -1)
|
|
else
|
|
JSON_BODY="$BODY"
|
|
fi
|
|
|
|
# `Not Acceptable` is a JSON-RPC error from the MCP server itself, returned
|
|
# with HTTP 200 if the SSE Accept header was missing. Detect it explicitly.
|
|
if echo "$JSON_BODY" | jq -e '.error.message | test("[Nn]ot [Aa]cceptable")' >/dev/null 2>&1; then
|
|
emit "malformed" "" "" "MALFORMED" "Accept-header gotcha: pass both 'application/json' AND 'text/event-stream'" false "$BODY"
|
|
exit 1
|
|
fi
|
|
|
|
SERVER_NAME=$(echo "$JSON_BODY" | jq -r '.result.serverInfo.name // empty' 2>/dev/null)
|
|
SERVER_VERSION=$(echo "$JSON_BODY" | jq -r '.result.serverInfo.version // empty' 2>/dev/null)
|
|
|
|
if [ -z "$SERVER_NAME" ] || [ -z "$SERVER_VERSION" ]; then
|
|
emit "malformed" "" "" "MALFORMED" "server may be on a newer gbrain version; missing result.serverInfo. Verify with: curl -H 'Accept: application/json, text/event-stream'" false "$BODY"
|
|
exit 1
|
|
fi
|
|
|
|
# --- Capability probe: tools/list to detect sources_add ---
|
|
# Best-effort. A failure here doesn't fail the verify; we just default
|
|
# sources_add_url_supported=false. Future gbrain versions that ship
|
|
# mcp__gbrain__sources_add will flip this true and gstack-artifacts-init
|
|
# will print the one-liner form instead of the clone-then-path form.
|
|
URL_SUPPORTED=false
|
|
TOOLS_BODY_FILE=$(mktemp -t gstack-mcp-tools.XXXXXX)
|
|
TOOLS_REQ='{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
|
|
|
|
set +e
|
|
curl -s -o "$TOOLS_BODY_FILE" \
|
|
--max-time 10 \
|
|
-X POST \
|
|
-H 'Content-Type: application/json' \
|
|
-H 'Accept: application/json, text/event-stream' \
|
|
-H "Authorization: Bearer $GBRAIN_MCP_TOKEN" \
|
|
-d "$TOOLS_REQ" \
|
|
"$URL" >/dev/null 2>&1
|
|
TOOLS_EXIT=$?
|
|
set -e
|
|
|
|
if [ "$TOOLS_EXIT" -eq 0 ]; then
|
|
TOOLS_BODY=$(cat "$TOOLS_BODY_FILE" 2>/dev/null || echo "")
|
|
if echo "$TOOLS_BODY" | head -1 | grep -q '^event:'; then
|
|
TOOLS_JSON=$(echo "$TOOLS_BODY" | sed -n 's/^data: //p' | head -1)
|
|
else
|
|
TOOLS_JSON="$TOOLS_BODY"
|
|
fi
|
|
if echo "$TOOLS_JSON" | jq -e '.result.tools[] | select(.name | test("sources_add"))' >/dev/null 2>&1; then
|
|
URL_SUPPORTED=true
|
|
fi
|
|
fi
|
|
rm -f "$TOOLS_BODY_FILE"
|
|
|
|
emit "success" "$SERVER_NAME" "$SERVER_VERSION" "" "" "$URL_SUPPORTED" "$BODY"
|
|
exit 0
|