Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
54b4aea
ci: add MCP interface diff workflow for Everything server
SamMorrowDrums Jan 28, 2026
b249e82
fix: update mcp-server-diff to v2.3.5
SamMorrowDrums Jan 28, 2026
09bb3d0
fix: use full paths instead of working-directory
SamMorrowDrums Jan 28, 2026
ab74e1c
ci: add concurrency group and fix mcp-server-diff SHA pin
inexistenzz Jun 10, 2026
8a42aea
ci: post sticky PR comment with MCP diff report
inexistenzz Jun 10, 2026
252a504
ci: also run MCP diff on TypeScript release tags
inexistenzz Jun 10, 2026
a837870
ci(everything): run the interface diff as an npm script on v2/main (#…
cliffhall Oct 4, 2026
1d25537
docs: record the interface diff as a gate stage (#4860)
cliffhall Oct 4, 2026
bae0bcb
ci(everything): diff a Release against the previous Release (#4860)
cliffhall Oct 4, 2026
dd6f08a
fix(interface-diff): check out the event's SHA; report the CLI's stde…
cliffhall Oct 4, 2026
9180711
fix(interface-diff): diff a Release against its own predecessor (#4860)
cliffhall Oct 4, 2026
1fe8b98
fix(interface-diff): Dependabot, root tsconfig, Windows quoting (#4860)
cliffhall Oct 4, 2026
714eea7
fix(interface-diff): give each push and release run its own group (#4…
cliffhall Oct 4, 2026
0e151f1
fix(interface-diff): never pass a report without a verdict (#4860)
cliffhall Oct 4, 2026
4979c6e
fix(interface-diff): bound the job summary; watch the Windows helper …
cliffhall Oct 4, 2026
a47bf98
fix(interface-diff): drop target_ref; keep both streams in errors (#4…
cliffhall Oct 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .claude/skills/pre-push-gate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,31 @@ of the server's stderr. Re-run a subset with
- **A new server fails `test:scripts`** with "every server directory in the
repo has a smoke entry": add it to `SERVERS` in `scripts/smoke-servers.mjs`.

### `interface-diff`

The last stage diffs the `everything` server's MCP interface (capabilities,
tools, prompts, resources, resource templates) in this checkout's build
against the same server built from the merge-base with `origin/v2/main`, and
prints the report. **A changed interface does not fail it**: the report is
what the PR's sticky comment will show, so read it, and if a change is not
one you meant, that is the finding. It fails only when the diff cannot run,
with `❌ The interface diff could not run` and the reason:

- **"origin/v2/main is not available"**: `git fetch origin v2/main`.
- **"src/everything/dist/index.js does not exist"**: you ran it on its own
before building: `npm run build -w src/everything`.
- **An `npm ci` or `npm run build` failure**: the _base_ did not install or
build. Offline with a cold npm cache is the usual cause; otherwise name a
base that builds with `npm run interface-diff -- --base <ref>`.
- **"Base server probe failed" / "Target probe failed"**: a server did not
answer the probe. A target failure is this checkout's server and is the
real defect; the smoke stage before it usually fails first.

Re-run it on its own with `npm run interface-diff` (about ten seconds). What
it compares against in CI, and why it is a script rather than an action, is
in [`docs/quality-gate.md`](../../../docs/quality-gate.md) and the header of
`scripts/interface-diff.mjs`.

## Waiting on the lease

A gate that starts with
Expand Down
254 changes: 254 additions & 0 deletions .github/workflows/everything-mcp-diff.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
# Tracks public interface changes to the Everything MCP server.
# The Everything server is a reference implementation demonstrating all MCP features.
# This workflow helps reviewers see how tools, resources, prompts, and capabilities
# evolve over time - useful for SDK compliance validation and catching regressions.
#
# Contributed by @SamMorrowDrums in #3260 and adapted for v2/main in #4860:
#
# - The check is `npm run interface-diff` (scripts/interface-diff.mjs), which
# drives the `mcp-server-diff` CLI, an exact-pinned root devDependency. The
# same script is a `local:gate` stage, and `scripts/lib/workflow-gate.test.mjs`
# requires every npm script a workflow runs to be one, so CI and the gate run
# the same check. No third-party action runs here.
# - The base is chosen per event below rather than by the tool, whose default
# (the merge-base with origin/main) is the wrong base for PRs to v2/main.
# - Untrusted code (the PR's install, build and server) runs only in `diff`,
# which holds a read-only token and no persisted credentials. The sticky
# comment is posted by `comment`, which holds `pull-requests: write` and runs
# none of the PR's code, and only for PRs from this repository: a fork PR's
# token is read-only, so there the report is in the job summary and the
# `mcp-diff-report` artifact. `pull_request_target` is deliberately not used,
# since it would run the fork's build next to a write token. If fork PRs need
# the comment too, the way is a separate `workflow_run` workflow that posts
# the artifact without checking anything out.
#
# A changed interface does not fail the run: the diff is information for the
# reviewer. A diff that cannot run (a base that does not build, a server that
# cannot be probed) does.
#
# See: https://github.com/modelcontextprotocol/inspector/issues/1034
name: Everything Server MCP Diff

on:
push:
branches: [main, v2/main]
paths:
- "src/everything/**"
- "package.json"
- "package-lock.json"
- "tsconfig.json"
- "scripts/interface-diff.mjs"
- "scripts/lib/win-shell-args.mjs"
- ".github/workflows/everything-mcp-diff.yml"
# No branch filter: PRs to v2/main and to main both run, and so does a PR
# stacked on another PR's branch. The base is the PR's own base either way.
pull_request:
paths:
- "src/everything/**"
- "package.json"
- "package-lock.json"
- "tsconfig.json"
- "scripts/interface-diff.mjs"
- "scripts/lib/win-shell-args.mjs"
- ".github/workflows/everything-mcp-diff.yml"
# A published Release (a milestone, tagged vX.Y.Z on main; see RELEASING.md)
# surfaces the cumulative interface delta since the previous Release. This
# replaces #3260's `typescript-servers-*` tag trigger: those tags stopped in
# 2024, and no release since has used them.
release:
types: [published]
# Manual trigger: the branch chosen in "Use workflow from" is the head, and
# `compare_ref` any base. #3260 also took a `target_ref`, but a target that
# predates the interface-diff script has no `npm run interface-diff` to run,
# so the head is the dispatched branch instead.
workflow_dispatch:
inputs:
compare_ref:
description: "Base ref to compare against (commit SHA, tag, or branch; defaults to the merge-base with v2/main)"
required: false
default: ""

permissions:
contents: read

# Superseded runs on the same PR are cancelled. Every other run gets a group
# of its own: a group holds one running and one pending run, so a shared group
# would drop a push's run when two more queued behind it.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
diff:
name: Diff Everything Server Interface
timeout-minutes: 15
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
# The base is built from history, so all of it is needed, tags
# included (the release base is the previous Release's tag).
fetch-depth: 0
# The event's own commit (the merge commit for a PR), not a branch
# ref that a later push could move before this step runs.
ref: ${{ github.sha }}
persist-credentials: false

- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm

# A root `npm ci` runs every workspace's `prepare`, which builds it, so
# the head build the diff probes exists after this step.
- name: Install dependencies and build
run: npm ci

- name: Choose the base to compare against
id: base
env:
EVENT: ${{ github.event_name }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
BEFORE: ${{ github.event.before }}
INPUT_BASE: ${{ inputs.compare_ref }}
TAG: ${{ github.event.release.tag_name }}
PUBLISHED: ${{ github.event.release.published_at }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
run: |
case "$EVENT" in
# The checkout is the PR's merge commit, so its base tip is
# exactly what the PR changes.
pull_request) base="$PR_BASE" ;;
# The branch as it was before this push; a new branch has none.
push)
if [ -n "$BEFORE" ] && [ "$BEFORE" != 0000000000000000000000000000000000000000 ]; then
base="$BEFORE"
else
base=$(git rev-parse HEAD^)
fi ;;
# The Release published just before this one (so a re-run of an
# older Release still compares against ITS predecessor). Not
# `git describe`: the date-stamped releases tagged commits that
# never reached main, so the nearest ancestor tag is years older
# than the last release.
release)
base=$(gh release list --repo "$REPO" --exclude-drafts --exclude-pre-releases --limit 50 \
--json tagName,publishedAt \
--jq "map(select(.tagName != \"$TAG\" and .publishedAt < \"$PUBLISHED\")) | sort_by(.publishedAt) | last | .tagName // empty")
if [ -z "$base" ]; then echo "no earlier Release to compare against" >&2; exit 1; fi ;;
workflow_dispatch)
if [ -n "$INPUT_BASE" ]; then
base="$INPUT_BASE"
else
base=$(git merge-base HEAD origin/v2/main)
fi ;;
*) echo "unexpected event: $EVENT" >&2; exit 1 ;;
esac
echo "Comparing against $base"
echo "ref=$base" >> "$GITHUB_OUTPUT"

- name: Diff the interface
env:
BASE: ${{ steps.base.outputs.ref }}
run: npm run interface-diff -- --base "$BASE" --report-dir mcp-diff-report

- name: Write the job summary
if: always()
run: |
{
if [ -f mcp-diff-report/report.md ]; then
# A step summary holds at most 1 MiB.
head -c 900000 mcp-diff-report/report.md
if [ "$(wc -c < mcp-diff-report/report.md)" -gt 900000 ]; then
echo ""
echo "_The report is truncated here; the full report is in the run's \`mcp-diff-report\` artifact._"
fi
else
echo "The interface diff produced no report; see the job log."
fi
echo ""
echo "---"
echo ""
echo "ℹ️ **Interpreting Results**"
echo ""
echo "The Everything server is a reference implementation that exercises all MCP features."
echo "Interface changes here may indicate:"
echo "- New MCP protocol features being demonstrated"
echo "- Updated tool schemas or descriptions"
echo "- New resources, prompts, or capabilities"
echo ""
echo "Review changes to ensure they align with the intended protocol updates."
} >> "$GITHUB_STEP_SUMMARY"

- name: Upload the report
if: always()
uses: actions/upload-artifact@v7
with:
name: mcp-diff-report
path: mcp-diff-report/
if-no-files-found: ignore

# Post (or update) a sticky PR comment so reviewers can see the diff without
# navigating to the Actions tab. One comment per PR, found again by its
# marker and edited on each push. This job checks nothing out and runs none
# of the PR's code; it only reads the report the `diff` job uploaded.
# Dependabot's PRs are same-repo but get a read-only token, so they are
# skipped like fork PRs and keep the job summary and the artifact.
comment:
name: Post the diff on the PR
needs: diff
if: >-
always() &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.user.login != 'dependabot[bot]' &&
(needs.diff.result == 'success' || needs.diff.result == 'failure')
timeout-minutes: 5
runs-on: ubuntu-latest
permissions:
pull-requests: write

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declined, as outside #4860 and not a new exposure. A same-repo PR can only come from someone with write access, and anyone with write access can already push a branch with any workflow on it, this one included, so this job adds no capability they lack. #4860 asked to skip the comment on forks rather than use pull_request_target, and that is what this does. Moving the posting to a trusted workflow_run workflow is recorded in the workflow header and the PR body as the path if fork PRs ever need the comment. It is listed as a follow-up candidate, not done here.

steps:
- name: Download the report
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: mcp-diff-report
path: mcp-diff-report

- name: Post / update sticky PR comment
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
marker='<!-- everything-mcp-diff -->'
{
echo "$marker"
if [ -f mcp-diff-report/report.md ]; then
# A comment holds at most 65536 characters.
head -c 60000 mcp-diff-report/report.md
if [ "$(wc -c < mcp-diff-report/report.md)" -gt 60000 ]; then
echo ""
echo "_The report is truncated here; the full report is in the run's \`mcp-diff-report\` artifact._"
fi
else
echo "## \`everything\`: MCP interface diff"
echo ""
echo "❌ The diff job produced no report; see the run's log."
fi
echo ""
echo "<sub>Updated by [this run]($RUN_URL) for \`$HEAD_SHA\`.</sub>"
} > body.md
id=$(gh api --paginate "repos/$REPO/issues/$PR/comments" \
--jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$marker\"))) | .id" | head -n 1)
if [ -n "$id" ]; then
gh api -X PATCH "repos/$REPO/issues/comments/$id" -F body=@body.md > /dev/null
else
gh api "repos/$REPO/issues/$PR/comments" -F body=@body.md > /dev/null
fi
18 changes: 10 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,14 @@ servers/
│ └── time/ Py mcp-server-time PyPI Time and timezone conversion
├── .claude/skills/ On-demand procedures (see the Skills index above)
├── .changeset/ Pending changesets for the TypeScript servers, and the changesets config
├── scripts/ The pre-push gate (gate-lease, smoke-servers, validate-py), its guards (verify-*),
├── scripts/ The pre-push gate (gate-lease, smoke-servers, validate-py, interface-diff), its guards (verify-*),
│ the skills tooling, and release tooling (npm-publish-guard, prepare-python-release,
│ pack-and-verify, release-manifest)
├── docs/ Design documents; quality-gate.md is the gate's reference (stages, CI vs local, the lease);
│ contribution-model.md holds the outside-PR backlog plan
├── .github/workflows/ typescript.yml, python.yml (per-package CI), release.yml (publishes on a GitHub Release),
│ version-packages.yml (the changesets PR), prepare-python-release.yml (the CalVer PR),
│ claude.yml (@claude mentions)
│ claude.yml (@claude mentions), everything-mcp-diff.yml (everything's interface diff on PRs)
├── .github/ISSUE_TEMPLATE/ Bug and feature issue forms; config.yml routes security and new servers away
├── .github/pull_request_template.md The "issues, not PRs" banner that turns outside PRs away
├── RELEASING.md How packages are versioned and published, and how to recover a failed publish
Expand Down Expand Up @@ -121,14 +121,16 @@ It runs every check CI runs, for both languages, in one command:
`verify:install-fresh`, the root `validate` (the guards, then each TypeScript
workspace's format check, lint, typecheck, build and tests), `validate:py`
(each Python server's locked sync, `ruff check`, `ruff format --check`,
pyright, pytest and build), `verify:skills:cli`, and `smoke` (every server
booted over each transport it implements). The stage-by-stage reference, and
what CI runs where, is [`docs/quality-gate.md`](./docs/quality-gate.md);
diagnosing a red stage is the `pre-push-gate` skill.
pyright, pytest and build), `verify:skills:cli`, `smoke` (every server
booted over each transport it implements), and `interface-diff` (the
`everything` server's MCP interface diffed against the base it branched
from). The stage-by-stage reference, and what CI runs where, is
[`docs/quality-gate.md`](./docs/quality-gate.md); diagnosing a red stage is
the `pre-push-gate` skill.

- **`npm run validate` and the per-package commands above are the inner loop,
not a substitute.** They skip the other language, the pinned skills validator
and the boot smoke.
not a substitute.** They skip the other language, the pinned skills validator,
the boot smoke and the interface diff.
- **Format before committing**: `npm run format` at the root (TypeScript), and
`uv run --frozen ruff format .` in a Python server you changed. The gate only
checks formatting; it never rewrites files.
Expand Down
18 changes: 15 additions & 3 deletions docs/quality-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ out; everything else points here.
| --- | --- | --- | --- |
| Inner loop | `npm run validate -w src/<server>`, `npm run validate:py -- <server>` | Your machine | While iterating on one server |
| Pre-push gate | `npm run local:gate` | Your machine, under a lease | Before every push |
| CI | `.github/workflows/typescript.yml`, `python.yml` | GitHub, on every push and pull request | Before merge |
| CI | `.github/workflows/typescript.yml`, `python.yml`, `everything-mcp-diff.yml` | GitHub, on every push and pull request (`everything-mcp-diff.yml`: when `everything` or what builds it changes) | Before merge |

**`npm run local:gate` runs every check CI runs.** CI splits the same checks
into parallel jobs on a fresh install; the gate runs them in sequence on your
Expand All @@ -39,6 +39,7 @@ in this order, and the first failure stops the run.
| 4 | `validate:py` | Per Python server: `uv sync --locked`, `ruff check`, `ruff format --check`, `pyright`, `pytest`, `uv build` | `python.yml` → **Test \<server\>** (one leg each) |
| 5 | `verify:skills:cli` | `claude plugin validate` on `.claude/skills`, at the pinned CLI version | `typescript.yml` → **Root guards** |
| 6 | `smoke` | Every server boots over each transport it implements and answers one tool call | `typescript.yml` → **Boot smoke** |
| 7 | `interface-diff` | The `everything` server's MCP interface (capabilities, tools, prompts, resources, resource templates) in this checkout's build, diffed against the same server built from the base. It fails only when the diff cannot run; a changed interface is reported, not failed | `everything-mcp-diff.yml` → **Diff Everything Server Interface** (and the PR comment) |

Notes on the stages:

Expand All @@ -55,8 +56,9 @@ Notes on the stages:
not the one installed: it fetches it with `npx`. `validate:py` needs it too
when a server's environment is missing or behind its lockfile, since
`uv sync --locked` then downloads packages (and the pinned interpreter, if
`uv` does not have it). With warm caches those are the only two stages that
can fail offline.
`uv` does not have it), and so does `interface-diff` for the base's
`npm ci`. With warm caches those are the only three stages that can fail
offline.
- **`smoke` launches what a user launches**: the built `dist/index.js` for a
TypeScript server, the console script through `uv run --no-sync` for a Python
one. stdio for all seven; HTTP+SSE and Streamable HTTP as well for
Expand All @@ -66,6 +68,16 @@ Notes on the stages:
than the default 3001. It runs after `validate` and `validate:py` because it
needs the build and each Python server's synced environment; it creates
neither.
- **`interface-diff` compares against the merge-base with `origin/v2/main`**
locally (`-- --base <ref>` names another), and against the PR's base, the
commit before the push, or the previous release's tag in CI. It builds the
base from an export of that commit under `node_modules/.cache/` and removes
it afterwards; the head is the build `validate` made. It drives the
`mcp-server-diff` CLI, an exact-pinned root devDependency, which probes with
the 2026-07-28 `server/discover` first and falls back to the `initialize`
handshake, so it covers a server in either era. Its `npm ci` of the base
needs the network when the npm cache is cold, and it needs
`origin/v2/main` fetched. Only `everything` is diffed today (#4860).
- `python.yml` also has a **Build \<server\>** job per server that re-runs
pyright and `uv build` and uploads the built distribution. It checks nothing
stage 4 does not.
Expand Down
Loading
Loading