From 237a6b9707eecf4472e71ccbabf21b5befabc73c Mon Sep 17 00:00:00 2001 From: Tobin South Date: Mon, 18 May 2026 11:24:31 -0700 Subject: [PATCH] Add CI check for HTTP MCP server URL liveness (#1910) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks marketplace.json for vendored plugins, extracts http/sse MCP server URLs from .mcp.json / mcp.json / plugin.json, and probes each with HEAD then a JSON-RPC POST fallback. Fails on 404/410 and connection errors; passes on auth/method errors (expected without credentials). Runs on PR, daily schedule, and manual dispatch. External (SHA-pinned) plugins are out of scope — their .mcp.json isn't checked out here. --- .github/workflows/check-mcp-urls.yml | 129 +++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 .github/workflows/check-mcp-urls.yml diff --git a/.github/workflows/check-mcp-urls.yml b/.github/workflows/check-mcp-urls.yml new file mode 100644 index 0000000..5bcdabf --- /dev/null +++ b/.github/workflows/check-mcp-urls.yml @@ -0,0 +1,129 @@ +name: Check MCP URLs + +# Liveness check for http/sse MCP server URLs declared by plugins vendored +# in this repo. Catches typos in new submissions and upstream endpoints that +# disappear after merge. +# +# Scope: only plugins whose files live in this working tree (marketplace +# entries with a string `source`, e.g. "./plugins/foo"). External entries +# are pinned to an upstream repo at a SHA — reading their .mcp.json would +# mean cloning every upstream on each run, which is slow and flaky. Those +# are out of scope for now. +# +# What counts as "alive": anything that proves the hostname/path resolves to +# a server. 401/403/405/5xx all pass — auth and method errors are expected +# without credentials. Only 404/410 and connection/DNS/TLS failures fail. + +on: + pull_request: + paths: + - '.claude-plugin/marketplace.json' + - 'plugins/**' + - 'external_plugins/**' + - '.github/workflows/check-mcp-urls.yml' + schedule: + - cron: '0 6 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Discover and probe MCP server URLs + run: | + set -euo pipefail + + MARKETPLACE=".claude-plugin/marketplace.json" + + # Each line: "\t\t". Marketplace entries with a + # string `source` are local paths; objects describe an external repo + # pinned at a SHA, which we don't have checked out — skip those. + discover() { + jq -r '.plugins[] | select(.source | type == "string") | "\(.name)\t\(.source)"' "$MARKETPLACE" | + while IFS=$'\t' read -r plugin src; do + dir="${src#./}" + [[ -d "$dir" ]] || continue + for cfg in "$dir/.mcp.json" "$dir/mcp.json" "$dir/.claude-plugin/plugin.json"; do + [[ -f "$cfg" ]] || continue + # MCP config comes in two shapes: a bare map of server name -> + # config, or wrapped under a top-level "mcpServers" key (also + # the shape inside plugin.json). Normalize, then keep entries + # with an http/sse type and a string url. + jq -r --arg plugin "$plugin" ' + (if (type == "object" and has("mcpServers")) then .mcpServers else . end) + | to_entries[] + | select((.value | type) == "object") + | select(.value.type == "http" or .value.type == "sse") + | select(.value.url | type == "string") + | "\($plugin)\t\(.key)\t\(.value.url)" + ' "$cfg" 2>/dev/null || true + done + done | sort -u + } + + # Returns 0 on pass, 1 on fail; prints "PASS|FAIL ". + probe() { + local url="$1" + local code + # HEAD first — cheap and covers plain web endpoints. -L follows + # redirects so a permanent redirect to a live page still passes. + code="$(curl -sS -o /dev/null -w '%{http_code}' \ + --connect-timeout 10 --max-time 10 \ + --retry 2 --retry-delay 2 \ + -L -I "$url" 2>/dev/null || echo "000")" + + # MCP endpoints typically reject HEAD (404/405) but answer POST + # with a JSON-RPC body. Retry as a real MCP client would. + if [[ "$code" == "000" || "$code" == "404" || "$code" == "405" ]]; then + code="$(curl -sS -o /dev/null -w '%{http_code}' \ + --connect-timeout 10 --max-time 10 \ + --retry 2 --retry-delay 2 \ + -L -X POST \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}' \ + "$url" 2>/dev/null || echo "000")" + fi + + case "$code" in + 000) echo "FAIL $code unreachable"; return 1 ;; + 404|410) echo "FAIL $code gone"; return 1 ;; + *) echo "PASS $code"; return 0 ;; + esac + } + + entries="$(discover)" + if [[ -z "$entries" ]]; then + echo "::notice::No http/sse MCP server URLs found in vendored plugins." + exit 0 + fi + + failures=0 + printf '%-24s %-18s %-52s %s\n' "PLUGIN" "SERVER" "URL" "RESULT" + while IFS=$'\t' read -r plugin server url; do + # Skip URLs with template placeholders — they need user config + # and can't be probed as-is. + if [[ "$url" == *'${'* || "$url" == *'{{'* ]]; then + printf '%-24s %-18s %-52s %s\n' "$plugin" "$server" "$url" "SKIP templated" + continue + fi + result="$(probe "$url")" || true + printf '%-24s %-18s %-52s %s\n' "$plugin" "$server" "$url" "$result" + if [[ "$result" == FAIL* ]]; then + failures=$((failures + 1)) + echo "::error::MCP server URL for plugin '$plugin' (server '$server') is unreachable: $url ($result)" + fi + done <<< "$entries" + + echo + if (( failures > 0 )); then + echo "::error::$failures MCP server URL(s) failed liveness check." + exit 1 + fi + echo "All MCP server URLs reachable."