MCP Check #22
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: MCP Check | |
| # Behavioural check on the MCP server this plugin points at. | |
| # | |
| # The plugin's .mcp.json is a copy of an install definition that lives | |
| # server-side. It has drifted before: the config carried a hardcoded | |
| # `Authorization: Bearer ${GREPTILE_API_KEY}` header, which sent an unexpanded | |
| # literal on the wire whenever the variable was unset AND disabled Claude | |
| # Code's OAuth fallback (setting headers.Authorization suppresses it), leaving | |
| # no way to recover from the /mcp menu. Diffing text would not have caught | |
| # that. Connecting does. | |
| # | |
| # No secrets: both `tools/list` and the 401 challenge on `tools/call` are | |
| # reachable unauthenticated. | |
| # | |
| # Everything here reads the manifest from disk. On a fork pull request that | |
| # manifest is attacker-controlled, so its contents must never reach shell | |
| # source: no `${{ }}` interpolation inside a `run:` block, and no step outputs | |
| # carrying manifest values. | |
| on: | |
| push: | |
| branches: [main] | |
| paths: | |
| - 'plugins/**' | |
| - '.github/workflows/mcp-check.yml' | |
| pull_request: | |
| paths: | |
| - 'plugins/**' | |
| - '.github/workflows/mcp-check.yml' | |
| schedule: | |
| - cron: '17 8 * * *' | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| jobs: | |
| check: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | |
| - name: Resolve the declared server config | |
| run: | | |
| set -euo pipefail | |
| config=plugins/greptile/.mcp.json | |
| # Both shapes are accepted by Claude Code: a bare server map, and one | |
| # wrapped in `mcpServers`. Normalise before asserting anything, so a | |
| # later move to the wrapped form cannot silently skip these checks. | |
| jq -e 'if has("mcpServers") then .mcpServers else . end' "$config" > "$RUNNER_TEMP/servers.json" | |
| if ! jq -e '.greptile | type == "object"' "$RUNNER_TEMP/servers.json" >/dev/null; then | |
| echo "::error::$config does not declare a \"greptile\" server object in either the bare or mcpServers shape." | |
| exit 1 | |
| fi | |
| if ! jq -e '.greptile.url | type == "string" and startswith("https://")' "$RUNNER_TEMP/servers.json" >/dev/null; then | |
| echo "::error::$config does not declare an https url for the greptile server." | |
| exit 1 | |
| fi | |
| jq -r '.greptile.url' "$RUNNER_TEMP/servers.json" > "$RUNNER_TEMP/url.txt" | |
| echo "Resolved $(cat "$RUNNER_TEMP/url.txt")" | |
| - name: Config declares no Authorization header | |
| run: | | |
| set -euo pipefail | |
| if jq -e '.greptile | has("headers")' "$RUNNER_TEMP/servers.json" >/dev/null; then | |
| echo "::error::plugins/greptile/.mcp.json declares headers. An Authorization header disables Claude Code's OAuth fallback; the server authenticates over OAuth and needs none." | |
| exit 1 | |
| fi | |
| echo "No headers declared." | |
| - name: Server still advertises OAuth | |
| run: | | |
| set -euo pipefail | |
| url=$(cat "$RUNNER_TEMP/url.txt") | |
| response=$(curl -sS -o /dev/null -D - -X POST "$url" \ | |
| -H 'Content-Type: application/json' \ | |
| -H 'Accept: application/json, text/event-stream' \ | |
| -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_code_reviews","arguments":{}}}' \ | |
| -w 'http_code=%{http_code}\n') | |
| echo "$response" | |
| if ! grep -q 'http_code=401' <<<"$response"; then | |
| echo "::error::An unauthenticated tools/call on $url did not return 401. Claude Code starts the OAuth flow from that challenge." | |
| exit 1 | |
| fi | |
| if ! grep -qi '^www-authenticate:.*resource_metadata=' <<<"$response"; then | |
| echo "::error::$url returned 401 without an RFC 9728 www-authenticate challenge. Claude Code needs resource_metadata to discover the authorization server." | |
| exit 1 | |
| fi | |
| echo "OAuth challenge present." | |
| - name: README documents exactly the tools the server serves | |
| run: | | |
| set -euo pipefail | |
| url=$(cat "$RUNNER_TEMP/url.txt") | |
| curl -sS -X POST "$url" \ | |
| -H 'Content-Type: application/json' \ | |
| -H 'Accept: application/json, text/event-stream' \ | |
| -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' > "$RUNNER_TEMP/tools.json" | |
| python3 - <<'PY' | |
| import json, os, re, sys | |
| with open(os.path.join(os.environ['RUNNER_TEMP'], 'tools.json')) as fh: | |
| payload = json.load(fh) | |
| if 'result' not in payload: | |
| sys.exit(f"::error::tools/list returned no result: {json.dumps(payload)[:400]}") | |
| served = {t['name'] for t in payload['result']['tools']} | |
| if not served: | |
| sys.exit('::error::tools/list returned no tools.') | |
| readme = open('plugins/greptile/README.md').read() | |
| documented = set(re.findall(r'^- `([a-z_]+)`', readme, re.M)) | |
| documented |= set(re.findall(r'/ `([a-z_]+)`', readme)) | |
| missing = sorted(served - documented) | |
| extra = sorted(documented - served) | |
| if missing: | |
| print(f"::error::README does not document: {', '.join(missing)}") | |
| if extra: | |
| print(f"::error::README documents tools the server does not serve: {', '.join(extra)}") | |
| if missing or extra: | |
| sys.exit(1) | |
| print(f"README documents all {len(served)} served tools.") | |
| PY |