From 6966777ab27ff51bcdb5c44353dbcc32ddb3423c Mon Sep 17 00:00:00 2001 From: Radoslav Dimitrov Date: Thu, 25 Jun 2026 10:34:07 +0300 Subject: [PATCH 1/3] ci: auto-close PRs that try to publish servers via the repo Some contributors try to "publish" an MCP server by opening a PR that adds files under servers/ or edits data/seed.json, which is not how publishing works. Add a two-stage workflow that detects these PRs and closes them with a comment pointing at the mcp-publisher quickstart. Stage 1 (detect) runs on pull_request with a read-only token and only records the flagged PR number as an artifact. Stage 2 (close) runs via workflow_run in the trusted base context, re-validates the PR from the API, then comments, labels `invalid`, and closes. This avoids pull_request_target; the only input crossing the trust boundary is a sanitized PR number. Co-Authored-By: Claude Opus 4.8 --- .../workflows/close-invalid-publish-prs.yml | 136 ++++++++++++++++++ .../workflows/detect-invalid-publish-prs.yml | 79 ++++++++++ 2 files changed, 215 insertions(+) create mode 100644 .github/workflows/close-invalid-publish-prs.yml create mode 100644 .github/workflows/detect-invalid-publish-prs.yml diff --git a/.github/workflows/close-invalid-publish-prs.yml b/.github/workflows/close-invalid-publish-prs.yml new file mode 100644 index 00000000..c20a4db3 --- /dev/null +++ b/.github/workflows/close-invalid-publish-prs.yml @@ -0,0 +1,136 @@ +name: Close invalid publish PRs + +# Stage 2 of 2 (see detect-invalid-publish-prs.yml for stage 1). +# +# Runs in the trusted base-repo context (triggered by workflow_run, not by the +# PR itself), so it has a write token. It picks up the PR number flagged by +# stage 1 and, after independently re-validating the PR from the API, posts a +# comment pointing at the publishing docs, labels it `invalid`, and closes it. +# +# Trust boundary: the ONLY thing crossing from the untrusted stage is the PR +# number (sanitized to digits below). We re-derive every decision (state, +# author, changed files) here, so a wrong/stale artifact can never cause us to +# close a PR that doesn't actually match. + +on: + workflow_run: + workflows: ["Detect invalid publish PRs"] + types: [completed] + +permissions: + contents: read + pull-requests: write + issues: write # labels on PRs go through the issues API + actions: read # needed to list/download the stage-1 artifact + +jobs: + close: + if: github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RUN_ID: ${{ github.event.workflow_run.id }} + COMMENT_BODY: | + Hi @__AUTHOR__ 👋 — thanks for your interest in the MCP Registry! + + It looks like this PR is trying to publish an MCP server by adding or + editing files in this repository (under `servers/` or in + `data/seed.json`). That isn't how servers get published, so I'm closing + this PR automatically. + + **Servers are published with the [`mcp-publisher`](https://github.com/modelcontextprotocol/registry/blob/main/docs/modelcontextprotocol-io/quickstart.mdx) CLI**, not by opening a pull request against this repo. The CLI verifies that you own your namespace and submits your `server.json` directly to the live registry API. + + To publish your server, follow the **[Publishing Quickstart](https://github.com/modelcontextprotocol/registry/blob/main/docs/modelcontextprotocol-io/quickstart.mdx)**. In short: + + ```bash + # Build the publisher CLI + make publisher + + # Authenticate (e.g. via GitHub) and publish your server.json + ./bin/mcp-publisher login github + ./bin/mcp-publisher publish + ``` + + Note: `data/seed.json` is seed data for **local development only** — adding entries there does not publish anything to the registry. + + If you believe this was closed in error, please leave a comment and a maintainer will take a look. 🙇 + + _This is an automated message._ + + steps: + - name: Check for flagged-pr artifact + id: check + run: | + set -euo pipefail + names="$(gh api "repos/$GH_REPO/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[].name' || true)" + if printf '%s\n' "$names" | grep -qx "flagged-pr"; then + echo "found=true" >> "$GITHUB_OUTPUT" + else + echo "No flagged-pr artifact on run $RUN_ID; nothing to do." + echo "found=false" >> "$GITHUB_OUTPUT" + fi + + - name: Download flagged PR number + if: steps.check.outputs.found == 'true' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: flagged-pr + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Re-validate and close + if: steps.check.outputs.found == 'true' + run: | + set -euo pipefail + + # Sanitize: the only cross-boundary input. Keep digits only. + PR_NUMBER="$(tr -dc '0-9' < pr-number.txt)" + if [ -z "$PR_NUMBER" ]; then + echo "No valid PR number in artifact; skipping." + exit 0 + fi + echo "Re-validating PR #$PR_NUMBER" + + state="$(gh pr view "$PR_NUMBER" --json state --jq '.state')" + if [ "$state" != "OPEN" ]; then + echo "PR #$PR_NUMBER is $state, not OPEN; skipping." + exit 0 + fi + + assoc="$(gh pr view "$PR_NUMBER" --json authorAssociation --jq '.authorAssociation')" + case "$assoc" in + MEMBER|OWNER|COLLABORATOR) + echo "Author association '$assoc' is trusted; skipping." + exit 0 + ;; + esac + + # Idempotency: if a maintainer reopens after we labeled it, leave it be. + if gh pr view "$PR_NUMBER" --json labels --jq '.labels[].name' | grep -qx "invalid"; then + echo "PR #$PR_NUMBER already labeled 'invalid'; skipping." + exit 0 + fi + + mapfile -t FILES < <(gh pr view "$PR_NUMBER" --json files --jq '.files[].path') + publish_attempt=0 + other=0 + for f in "${FILES[@]}"; do + if [[ "$f" == servers/* || "$f" == "data/seed.json" ]]; then + publish_attempt=1 + else + other=1 + fi + done + if [ "$publish_attempt" -ne 1 ] || [ "$other" -ne 0 ]; then + echo "PR #$PR_NUMBER no longer matches the publish-attempt pattern; skipping." + exit 0 + fi + + author="$(gh pr view "$PR_NUMBER" --json author --jq '.author.login')" + body="${COMMENT_BODY//__AUTHOR__/$author}" + + echo "Commenting on, labeling, and closing PR #$PR_NUMBER" + gh pr comment "$PR_NUMBER" --body "$body" + gh pr edit "$PR_NUMBER" --add-label "invalid" || echo "Could not add label; continuing." + gh pr close "$PR_NUMBER" diff --git a/.github/workflows/detect-invalid-publish-prs.yml b/.github/workflows/detect-invalid-publish-prs.yml new file mode 100644 index 00000000..4c7dd800 --- /dev/null +++ b/.github/workflows/detect-invalid-publish-prs.yml @@ -0,0 +1,79 @@ +name: Detect invalid publish PRs + +# Stage 1 of 2 (see close-invalid-publish-prs.yml for stage 2). +# +# Some contributors try to "publish" a server by opening a PR that adds files +# under servers/ or edits data/seed.json. That is not how publishing works - +# servers are published with the mcp-publisher CLI against the live registry +# API. This job only DETECTS such PRs. It runs from the (untrusted) PR context +# with a read-only token, so it cannot comment on or close anything. When it +# finds a match it records just the PR number as an artifact; the privileged +# stage-2 workflow picks that up via workflow_run and acts on it. +# +# We never check out or run the PR's code - we only read changed file paths. + +on: + pull_request: + types: [opened, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + detect: + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }} + steps: + - name: Classify PR + id: classify + run: | + set -euo pipefail + match=false + + # Maintainers may legitimately touch these files (e.g. dev seed data), + # so never flag their PRs. + case "$AUTHOR_ASSOCIATION" in + MEMBER|OWNER|COLLABORATOR) + echo "Author association '$AUTHOR_ASSOCIATION' is trusted; not flagging." + ;; + *) + mapfile -t FILES < <(gh pr view "$PR_NUMBER" --json files --jq '.files[].path') + if [ "${#FILES[@]}" -gt 0 ]; then + # Flag only if the PR touches *exclusively* publish-attempt files + # (servers/** and/or data/seed.json) and nothing else, so that + # legit PRs touching seed data alongside real code are left alone. + publish_attempt=0 + other=0 + for f in "${FILES[@]}"; do + if [[ "$f" == servers/* || "$f" == "data/seed.json" ]]; then + publish_attempt=1 + else + echo "Non-publish file changed: $f" + other=1 + fi + done + if [ "$publish_attempt" -eq 1 ] && [ "$other" -eq 0 ]; then + match=true + fi + fi + ;; + esac + + echo "PR #$PR_NUMBER match=$match" + echo "match=$match" >> "$GITHUB_OUTPUT" + if [ "$match" = "true" ]; then + echo "$PR_NUMBER" > pr-number.txt + fi + + - name: Record flagged PR number + if: steps.classify.outputs.match == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + with: + name: flagged-pr + path: pr-number.txt + retention-days: 1 From 82add70fc8636fffec70726bb43e77283564b335 Mon Sep 17 00:00:00 2001 From: Radoslav Dimitrov Date: Thu, 25 Jun 2026 11:37:50 +0300 Subject: [PATCH 2/3] ci: harden publish-PR detection from review feedback - Use the paginated REST pulls/files endpoint instead of `gh pr view --json files`, which caps at 100 files with no pagination and could truncate a large PR into a false match. - Capture PR labels into a variable before grepping so a transient gh failure can't fail open on the idempotency guard. - Add an empty-file-list guard to stage 2 for parity with stage 1. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/close-invalid-publish-prs.yml | 12 ++++++++++-- .github/workflows/detect-invalid-publish-prs.yml | 5 ++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/close-invalid-publish-prs.yml b/.github/workflows/close-invalid-publish-prs.yml index c20a4db3..2ab284a7 100644 --- a/.github/workflows/close-invalid-publish-prs.yml +++ b/.github/workflows/close-invalid-publish-prs.yml @@ -107,12 +107,20 @@ jobs: esac # Idempotency: if a maintainer reopens after we labeled it, leave it be. - if gh pr view "$PR_NUMBER" --json labels --jq '.labels[].name' | grep -qx "invalid"; then + # Capture into a var first so a transient gh failure can't fail open + # (an errored fetch piped to grep would otherwise look "not labeled"). + labels="$(gh pr view "$PR_NUMBER" --json labels --jq '.labels[].name' || true)" + if printf '%s\n' "$labels" | grep -qx "invalid"; then echo "PR #$PR_NUMBER already labeled 'invalid'; skipping." exit 0 fi - mapfile -t FILES < <(gh pr view "$PR_NUMBER" --json files --jq '.files[].path') + # Paginated REST endpoint (gh pr view --json files caps at 100, no pagination). + mapfile -t FILES < <(gh api --paginate "repos/$GH_REPO/pulls/$PR_NUMBER/files" --jq '.[].filename') + if [ "${#FILES[@]}" -eq 0 ]; then + echo "No files for PR #$PR_NUMBER; skipping." + exit 0 + fi publish_attempt=0 other=0 for f in "${FILES[@]}"; do diff --git a/.github/workflows/detect-invalid-publish-prs.yml b/.github/workflows/detect-invalid-publish-prs.yml index 4c7dd800..355521b0 100644 --- a/.github/workflows/detect-invalid-publish-prs.yml +++ b/.github/workflows/detect-invalid-publish-prs.yml @@ -42,7 +42,10 @@ jobs: echo "Author association '$AUTHOR_ASSOCIATION' is trusted; not flagging." ;; *) - mapfile -t FILES < <(gh pr view "$PR_NUMBER" --json files --jq '.files[].path') + # Use the paginated REST endpoint: `gh pr view --json files` caps + # at 100 files with no pagination, which could truncate a large PR + # and cause a false match. + mapfile -t FILES < <(gh api --paginate "repos/$GH_REPO/pulls/$PR_NUMBER/files" --jq '.[].filename') if [ "${#FILES[@]}" -gt 0 ]; then # Flag only if the PR touches *exclusively* publish-attempt files # (servers/** and/or data/seed.json) and nothing else, so that From b27b0100cd0dcb448b9ee8621239b4a783f4287a Mon Sep 17 00:00:00 2001 From: Radoslav Dimitrov Date: Thu, 25 Jun 2026 11:41:54 +0300 Subject: [PATCH 3/3] ci: unwrap auto-comment paragraph so it renders cleanly GitHub renders single newlines in a comment paragraph as line breaks, so the hard-wrapped second paragraph showed mid-sentence breaks. Put it on one line. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/close-invalid-publish-prs.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/close-invalid-publish-prs.yml b/.github/workflows/close-invalid-publish-prs.yml index 2ab284a7..27f69a85 100644 --- a/.github/workflows/close-invalid-publish-prs.yml +++ b/.github/workflows/close-invalid-publish-prs.yml @@ -34,10 +34,7 @@ jobs: COMMENT_BODY: | Hi @__AUTHOR__ 👋 — thanks for your interest in the MCP Registry! - It looks like this PR is trying to publish an MCP server by adding or - editing files in this repository (under `servers/` or in - `data/seed.json`). That isn't how servers get published, so I'm closing - this PR automatically. + It looks like this PR is trying to publish an MCP server by adding or editing files in this repository (under `servers/` or in `data/seed.json`). That isn't how servers get published, so I'm closing this PR automatically. **Servers are published with the [`mcp-publisher`](https://github.com/modelcontextprotocol/registry/blob/main/docs/modelcontextprotocol-io/quickstart.mdx) CLI**, not by opening a pull request against this repo. The CLI verifies that you own your namespace and submits your `server.json` directly to the live registry API.