145 lines
6.3 KiB
YAML
145 lines
6.3 KiB
YAML
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. "./productivity"). 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'
|
||
- '**/.mcp.json'
|
||
- '**/mcp.json'
|
||
- '**/.claude-plugin/plugin.json'
|
||
- '.github/workflows/check-mcp-urls.yml'
|
||
schedule:
|
||
- cron: '0 6 * * *'
|
||
workflow_dispatch:
|
||
|
||
permissions:
|
||
contents: read
|
||
|
||
jobs:
|
||
check:
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 20
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
|
||
- name: Discover and probe MCP server URLs
|
||
run: |
|
||
set -euo pipefail
|
||
|
||
MARKETPLACE=".claude-plugin/marketplace.json"
|
||
|
||
# Each line: "<plugin>\t<server>\t<url>". 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 non-empty string url. Empty URLs
|
||
# are placeholders awaiting config and would false-fail.
|
||
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" and . != "")
|
||
| "\($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 <code> <note>".
|
||
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.
|
||
#
|
||
# On a connection-level failure curl writes "000" to -w AND exits
|
||
# nonzero. The fallback assignment must happen OUTSIDE the command
|
||
# substitution — `... || echo "000"` inside $() would *append* a
|
||
# second "000", producing "000000" which falls through the case
|
||
# statement and silently passes a dead host.
|
||
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)" || code="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)" || code="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
|
||
|
||
# Many vendored plugins share servers (slack, notion, atlassian …).
|
||
# Probe each distinct URL once and reuse the verdict so the run cost
|
||
# is bounded by unique URLs, not (plugins × servers).
|
||
declare -A verdict_for
|
||
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
|
||
if [[ -z "${verdict_for[$url]+x}" ]]; then
|
||
verdict_for["$url"]="$(probe "$url")" || true
|
||
fi
|
||
result="${verdict_for[$url]}"
|
||
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."
|