Files
anthropics__knowledge-work-…/.github/workflows/check-mcp-urls.yml
Tobin South 6445c15068 Add SHA bump pipeline, policy scan, and MCP URL liveness check (#236)
* Add SHA bump pipeline, policy scan, and MCP URL liveness check

Brings this marketplace's CI in line with claude-plugins-official:

- bump-plugin-shas.yml: nightly sweep that bumps every external entry's
  pinned SHA to upstream HEAD, validates each at the new SHA, opens one
  signed PR (createCommitOnBranch satisfies required_signatures), and
  dispatches the policy scan on the bump branch.

- scan-plugins.yml: Claude policy review of changed external entries.
  Caches verdicts in Actions cache keyed (plugin, sha, policy hash) so
  the nightly force-reset doesn't re-burn scan time on already-reviewed
  SHAs. Cached pass/fail verdicts are served from disk; only uncached
  SHAs are scanned. Fails closed when ANTHROPIC_API_KEY is unset.
  Designed to be a required status check on main.

- revert-failed-bumps.yml: drops policy-failing entries from a bump PR
  via a follow-up signed commit and re-dispatches the scan, so one bad
  upstream can't block the rest. Bounded at 3 passes/night, restricted
  to SHA-only diffs and first-party-repo PRs.

- check-mcp-urls.yml: probes every http/sse MCP server URL declared by
  vendored plugins. Fails on 404/410/unreachable; passes on auth/method
  errors (expected without credentials). Deduplicates shared servers so
  cost is bounded by unique URLs, not (plugins x servers). Skips empty
  and templated URLs.

- .github/policy/: the policy prompt and structured-output schema the
  scan reviews against.

Two operator steps after merge:
1. Set the ANTHROPIC_API_KEY repo secret (the scan refuses to silently
   skip a required policy review without it).
2. Add a required_status_checks ruleset on main requiring the `scan`
   check from GitHub Actions. Adding it before this merges would block
   every PR.

* Fix MCP URL probe: connection failure was reported as PASS

curl writes "000" to -w '%{http_code}' on a connection failure AND exits
nonzero. The previous fallback put the echo inside the command
substitution — both wrote, the captured value was "000000", and the
case statement's 000) arm didn't match, so dead hosts fell through to
PASS. Move the fallback assignment outside the substitution so the
captured value is exactly "000" and connection failures fail.
2026-05-18 21:19:13 -07:00

145 lines
6.3 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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."