check-mcp-urls.yml 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. name: Check MCP URLs
  2. # Liveness check for http/sse MCP server URLs declared by plugins vendored
  3. # in this repo. Catches typos in new submissions and upstream endpoints that
  4. # disappear after merge.
  5. #
  6. # Scope: only plugins whose files live in this working tree (marketplace
  7. # entries with a string `source`, e.g. "./plugins/foo"). External entries
  8. # are pinned to an upstream repo at a SHA — reading their .mcp.json would
  9. # mean cloning every upstream on each run, which is slow and flaky. Those
  10. # are out of scope for now.
  11. #
  12. # What counts as "alive": anything that proves the hostname/path resolves to
  13. # a server. 401/403/405/5xx all pass — auth and method errors are expected
  14. # without credentials. Only 404/410 and connection/DNS/TLS failures fail.
  15. on:
  16. pull_request:
  17. paths:
  18. - '.claude-plugin/marketplace.json'
  19. - 'plugins/**'
  20. - 'external_plugins/**'
  21. - '.github/workflows/check-mcp-urls.yml'
  22. schedule:
  23. - cron: '0 6 * * *'
  24. workflow_dispatch:
  25. permissions:
  26. contents: read
  27. jobs:
  28. check:
  29. runs-on: ubuntu-latest
  30. timeout-minutes: 15
  31. steps:
  32. - uses: actions/checkout@v4
  33. - name: Discover and probe MCP server URLs
  34. run: |
  35. set -euo pipefail
  36. MARKETPLACE=".claude-plugin/marketplace.json"
  37. # Each line: "<plugin>\t<server>\t<url>". Marketplace entries with a
  38. # string `source` are local paths; objects describe an external repo
  39. # pinned at a SHA, which we don't have checked out — skip those.
  40. discover() {
  41. jq -r '.plugins[] | select(.source | type == "string") | "\(.name)\t\(.source)"' "$MARKETPLACE" |
  42. while IFS=$'\t' read -r plugin src; do
  43. dir="${src#./}"
  44. [[ -d "$dir" ]] || continue
  45. for cfg in "$dir/.mcp.json" "$dir/mcp.json" "$dir/.claude-plugin/plugin.json"; do
  46. [[ -f "$cfg" ]] || continue
  47. # MCP config comes in two shapes: a bare map of server name ->
  48. # config, or wrapped under a top-level "mcpServers" key (also
  49. # the shape inside plugin.json). Normalize, then keep entries
  50. # with an http/sse type and a string url.
  51. jq -r --arg plugin "$plugin" '
  52. (if (type == "object" and has("mcpServers")) then .mcpServers else . end)
  53. | to_entries[]
  54. | select((.value | type) == "object")
  55. | select(.value.type == "http" or .value.type == "sse")
  56. | select(.value.url | type == "string")
  57. | "\($plugin)\t\(.key)\t\(.value.url)"
  58. ' "$cfg" 2>/dev/null || true
  59. done
  60. done | sort -u
  61. }
  62. # Returns 0 on pass, 1 on fail; prints "PASS|FAIL <code> <note>".
  63. probe() {
  64. local url="$1"
  65. local code
  66. # HEAD first — cheap and covers plain web endpoints. -L follows
  67. # redirects so a permanent redirect to a live page still passes.
  68. code="$(curl -sS -o /dev/null -w '%{http_code}' \
  69. --connect-timeout 10 --max-time 10 \
  70. --retry 2 --retry-delay 2 \
  71. -L -I "$url" 2>/dev/null || echo "000")"
  72. # MCP endpoints typically reject HEAD (404/405) but answer POST
  73. # with a JSON-RPC body. Retry as a real MCP client would.
  74. if [[ "$code" == "000" || "$code" == "404" || "$code" == "405" ]]; then
  75. code="$(curl -sS -o /dev/null -w '%{http_code}' \
  76. --connect-timeout 10 --max-time 10 \
  77. --retry 2 --retry-delay 2 \
  78. -L -X POST \
  79. -H 'Content-Type: application/json' \
  80. -H 'Accept: application/json, text/event-stream' \
  81. --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}' \
  82. "$url" 2>/dev/null || echo "000")"
  83. fi
  84. case "$code" in
  85. 000) echo "FAIL $code unreachable"; return 1 ;;
  86. 404|410) echo "FAIL $code gone"; return 1 ;;
  87. *) echo "PASS $code"; return 0 ;;
  88. esac
  89. }
  90. entries="$(discover)"
  91. if [[ -z "$entries" ]]; then
  92. echo "::notice::No http/sse MCP server URLs found in vendored plugins."
  93. exit 0
  94. fi
  95. failures=0
  96. printf '%-24s %-18s %-52s %s\n' "PLUGIN" "SERVER" "URL" "RESULT"
  97. while IFS=$'\t' read -r plugin server url; do
  98. # Skip URLs with template placeholders — they need user config
  99. # and can't be probed as-is.
  100. if [[ "$url" == *'${'* || "$url" == *'{{'* ]]; then
  101. printf '%-24s %-18s %-52s %s\n' "$plugin" "$server" "$url" "SKIP templated"
  102. continue
  103. fi
  104. result="$(probe "$url")" || true
  105. printf '%-24s %-18s %-52s %s\n' "$plugin" "$server" "$url" "$result"
  106. if [[ "$result" == FAIL* ]]; then
  107. failures=$((failures + 1))
  108. echo "::error::MCP server URL for plugin '$plugin' (server '$server') is unreachable: $url ($result)"
  109. fi
  110. done <<< "$entries"
  111. echo
  112. if (( failures > 0 )); then
  113. echo "::error::$failures MCP server URL(s) failed liveness check."
  114. exit 1
  115. fi
  116. echo "All MCP server URLs reachable."