Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ jobs:
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
version: 0.9.5
version: 0.11.33
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
Expand Down Expand Up @@ -90,7 +90,7 @@ jobs:
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
version: 0.9.5
version: 0.11.33
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
Expand Down
342 changes: 342 additions & 0 deletions .github/workflows/dependency-canary.yml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .github/workflows/deploy-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ jobs:
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
version: 0.9.5
version: 0.11.33

- name: Build combined docs (main at / and /v2/, v1.x at /v1/)
run: bash scripts/build-docs.sh site
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/docs-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ jobs:
# a cache populated while untrusted PR code ran would let it poison
# later trusted workflows. Mirrors publish-pypi.yml.
enable-cache: false
version: 0.9.5
version: 0.11.33

# pull_request_target runs this workflow file from the base branch, so
# the whole recipe — dependency sync included — must come from the
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: false
version: 0.9.5
version: 0.11.33

- name: Set up Python 3.12
run: uv python install 3.12
Expand Down
16 changes: 8 additions & 8 deletions .github/workflows/shared.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@ jobs:
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
version: 0.9.5
version: 0.11.33

- name: Install uv (retry)
if: steps.setup-uv.outcome == 'failure'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
version: 0.9.5
version: 0.11.33

- name: Install dependencies
run: uv sync --frozen --all-extras --python 3.10
Expand Down Expand Up @@ -81,14 +81,14 @@ jobs:
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
version: 0.9.5
version: 0.11.33

- name: Install uv (retry)
if: steps.setup-uv.outcome == 'failure'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
version: 0.9.5
version: 0.11.33

- name: Install the project
run: uv sync ${{ matrix.dep-resolution.install-flags }} --all-extras --python ${{ matrix.python-version }}
Expand Down Expand Up @@ -127,14 +127,14 @@ jobs:
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
version: 0.9.5
version: 0.11.33

- name: Install uv (retry)
if: steps.setup-uv.outcome == 'failure'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
version: 0.9.5
version: 0.11.33

- name: Install dependencies
run: uv sync --frozen --all-extras --python 3.10
Expand Down Expand Up @@ -167,14 +167,14 @@ jobs:
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
version: 0.9.5
version: 0.11.33

- name: Install uv (retry)
if: steps.setup-uv.outcome == 'failure'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
version: 0.9.5
version: 0.11.33

- name: Build the docs in strict mode
run: bash scripts/docs/build.sh
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,8 @@ results/

# conformance CI local runs
conformance-results/

# dependency canary local runs (scripts/ci/canary_*.sh)
canary-out/
canary-resolve/
canary-body.md
129 changes: 129 additions & 0 deletions scripts/ci/canary_cell.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# One dependency-canary test cell: install the resolved lock, run the suite,
# and classify the outcome so the report can tell a hard break from a new
# deprecation warning or a flake. Driven by .github/workflows/dependency-canary.yml.
#
# Inputs (env): CANARY_CELL (label, e.g. "ubuntu-3.14"), CANARY_PYTHON (e.g. "3.14"),
# CANARY_PYRIGHT=1 to append an informational `pyright src/mcp` result.
# Outputs under out/canary-cell-$CANARY_CELL/:
# status — pass | flaky | warnings-only | error | install-failed
# ("incomplete" is written first, so a killed cell still says something)
# cell.md — Markdown section for the issue body / job summary (bounded to ~20 kB)
# *.log — raw logs, kept in the artifact for debugging
# Exit status: 0 for pass/flaky, 1 otherwise (so the job shows red).

set -uo pipefail

cell="${CANARY_CELL:?}"
python="${CANARY_PYTHON:?}"
out="out/canary-cell-$cell"
mkdir -p "$out"
md="$out/cell.md"
echo incomplete >"$out/status"
printf '#### %s\n\nDid not finish (see the workflow run).\n' "$cell" >"$md"
export COLUMNS=200 # keeps pytest from truncating the -r summary lines quoted in the report

pytest_cmd=(uv run --frozen --no-sync pytest -p no:pretty -q --no-header -rfE --color=no -o log_cli=false)
# Demote only the deprecation family: if failures vanish under these, the newest
# versions still work and merely announce a future removal.
demote=(-W default::DeprecationWarning -W default::PendingDeprecationWarning -W default::FutureWarning)

summary_of() { # the short-summary FAILED/ERROR lines of a pytest log, capped; falls back to the log tail
local lines
lines=$(grep -E '^(FAILED|ERROR) ' "$1" | awk '!seen[$0]++' | cut -c1-240) # xdist repeats collection errors per worker
if [ -z "$lines" ]; then
tail -n 15 "$1" | cut -c1-240
return
fi
head -n 40 <<<"$lines"
local n
n=$(wc -l <<<"$lines")
if [ "$n" -gt 40 ]; then echo "... and $((n - 40)) more"; fi
}

details_of() { # collapsed, size-bounded tail of a log: file, summary text, max lines
printf '<details><summary>%s</summary>\n\n```text\n' "$2"
tail -n "${3:-60}" "$1" | cut -c1-240 | head -c 12000 | sed 's/```/` ` `/g'
printf '\n```\n\n</details>\n'
}

finish() {
echo "$1" >"$out/status"
echo "canary cell $cell: $1"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then cat "$md" >>"$GITHUB_STEP_SUMMARY"; fi
case "$1" in pass | flaky) exit 0 ;; *) exit 1 ;; esac
}

printf '#### %s\n\n' "$cell" >"$md"

if ! uv sync --frozen --all-extras --python "$python" >"$out/sync.log" 2>&1; then
{
echo "**Install failed** — \`uv sync --frozen --all-extras --python $python\` could not install the resolved set on this platform."
echo
details_of "$out/sync.log" "uv sync output" 40
} >>"$md"
finish install-failed
fi
installed=$(uv run --frozen --no-sync python -V 2>/dev/null || echo "Python $python")

# Phase 1: the suite as CI runs it (from a clean last-failed record, which phase 2 keys on).
rm -f .pytest_cache/v/cache/lastfailed
if "${pytest_cmd[@]}" -n auto >"$out/run1.log" 2>&1; then
echo "All tests pass ($installed)." >>"$md"
status=pass
else
# Phase 2: again, to drop flakes. Narrow to the recorded failures (serially) when
# pytest got far enough to record any; a collection/usage error leaves none.
if grep -qs '::' .pytest_cache/v/cache/lastfailed; then
rerun=("${pytest_cmd[@]}" --lf --last-failed-no-failures none -p no:xdist)
rerun_desc="a serial re-run of just the failing tests"
else
rerun=("${pytest_cmd[@]}" -n auto)
rerun_desc="a full re-run"
fi
if "${rerun[@]}" >"$out/run2.log" 2>&1; then
{
echo "Passed on $rerun_desc ($installed); the first attempt failed as below (treated as flaky, not reported):"
echo
echo '```text'
summary_of "$out/run1.log"
echo '```'
} >>"$md"
status=flaky
else
# Phase 3: same again with the deprecation family demoted, to tell "broken" from "deprecated".
if "${rerun[@]}" "${demote[@]}" >"$out/run3.log" 2>&1; then
{
echo "**Deprecation warnings only** ($installed) — the failures below persist on $rerun_desc but disappear once"
echo "\`DeprecationWarning\`/\`PendingDeprecationWarning\`/\`FutureWarning\` are not errors, so the newest versions still work and are announcing a removal we need to get ahead of."
} >>"$md"
status=warnings-only
else
echo "**Hard failures** ($installed) — the failures below persist on $rerun_desc and with deprecation warnings demoted:" >>"$md"
status=error
fi
{
echo
echo '```text'
summary_of "$out/run2.log"
echo '```'
echo
details_of "$out/run2.log" "pytest output (tail)" 80
} >>"$md"
fi
fi

if [ "${CANARY_PYRIGHT:-}" = "1" ]; then
# Typing-only drift (e.g. a dependency tightening a signature) never fails the cell; it is context for the reader.
if PYRIGHT_PYTHON_IGNORE_WARNINGS=1 uv run --frozen --no-sync pyright src/mcp >"$out/pyright.log" 2>&1; then
printf '\npyright on `src/mcp` against these versions: clean (informational).\n' >>"$md"
else
count=$(grep -Eo '^[0-9]+ errors?' "$out/pyright.log" | tail -n1)
{
echo
details_of "$out/pyright.log" "pyright on <code>src/mcp</code> against these versions: ${count:-did not complete} (informational, never filed on its own)" 30
} >>"$md"
fi
fi

finish "$status"
113 changes: 113 additions & 0 deletions scripts/ci/canary_lock_diff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Diff two uv.lock files for the dependency canary report.

Usage: python scripts/ci/canary_lock_diff.py OLD NEW [--old-label L] [--new-label L] [--suspects FILE]

Prints a Markdown table of every package whose locked version(s) differ between
OLD and NEW, tagged by its role relative to the root `mcp` package: a direct
runtime dependency, a transitive runtime dependency (reachable from `mcp` with
all extras, ignoring markers), or tooling (only reachable through a dependency
group). Prints nothing when the two locks agree. With --suspects, also writes a
one-line summary of the changed runtime dependencies (direct first) to FILE,
for use in an issue title.
"""

from __future__ import annotations

import argparse
import sys
from pathlib import Path
from typing import Any

import tomllib

Package = dict[str, Any]

ROOT = "mcp"


def load(path: Path) -> list[Package]:
return tomllib.loads(path.read_text(encoding="utf-8")).get("package", [])


def versions(packages: list[Package]) -> dict[str, list[str]]:
"""name -> sorted distinct versions (a name can be locked more than once across marker forks)."""
out: dict[str, set[str]] = {}
for pkg in packages:
if "version" in pkg and "editable" not in pkg.get("source", {}) and "virtual" not in pkg.get("source", {}):
out.setdefault(pkg["name"], set()).add(pkg["version"])
return {name: sorted(vs) for name, vs in out.items()}


def runtime_roles(packages: list[Package]) -> tuple[set[str], set[str]]:
"""(direct, closure): names mcp depends on directly (any extra), and everything reachable from them."""
by_name: dict[str, list[Package]] = {}
for pkg in packages:
by_name.setdefault(pkg["name"], []).append(pkg)

def edges(name: str, extras: frozenset[str]) -> list[tuple[str, frozenset[str]]]:
found: list[tuple[str, frozenset[str]]] = []
for pkg in by_name.get(name, []):
deps = list(pkg.get("dependencies", []))
for extra in extras:
deps += pkg.get("optional-dependencies", {}).get(extra, [])
found += [(d["name"], frozenset(d.get("extra", []))) for d in deps]
return found

root_extras = frozenset().union(*(pkg.get("optional-dependencies", {}).keys() for pkg in by_name.get(ROOT, [])))
direct = {name for name, _ in edges(ROOT, root_extras)}
closure: set[str] = set()
seen: set[tuple[str, frozenset[str]]] = set()
todo = [(ROOT, root_extras)]
while todo:
node = todo.pop()
if node in seen:
continue
seen.add(node)
closure.add(node[0])
todo += edges(*node)
closure.discard(ROOT)
return direct, closure


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("old", type=Path)
parser.add_argument("new", type=Path)
parser.add_argument("--old-label", default="before")
parser.add_argument("--new-label", default="after")
parser.add_argument("--suspects", type=Path, help="write a one-line title summary of changed runtime deps here")
args = parser.parse_args()

old_packages, new_packages = load(args.old), load(args.new)
old, new = versions(old_packages), versions(new_packages)
# Union of both locks' graphs, so a dependency dropped by the new resolution keeps its old role.
direct, closure = (a | b for a, b in zip(runtime_roles(old_packages), runtime_roles(new_packages)))

def role(name: str) -> tuple[int, str]:
if name in direct:
return 0, "runtime (direct)"
if name in closure:
return 1, "runtime (transitive)"
return 2, "tooling"

changed = sorted((role(n), n) for n in old.keys() | new.keys() if old.get(n) != new.get(n))
# Tooling that merely appears or disappears (e.g. a dependency group stripped before resolving) is noise here.
changed = [c for c in changed if c[0][0] < 2 or (c[1] in old and c[1] in new)]
if changed:
print(f"| Package | {args.old_label} | {args.new_label} | Role |")
print("| --- | --- | --- | --- |")
for (_, label), name in changed:
before = ", ".join(old.get(name, [])) or "(absent)"
after = ", ".join(new.get(name, [])) or "(removed)"
print(f"| {name} | {before} | {after} | {label} |")

if args.suspects:
runtime = [f"{n} {new[n][-1]}" for (rank, _), n in changed if rank < 2 and n in new]
summary = ", ".join(runtime[:3]) + (f" (+{len(runtime) - 3} more)" if len(runtime) > 3 else "")
# Empty file (not a blank line) when no runtime dependency changed, so `[ -s FILE ]` means what it says.
args.suspects.write_text(summary + "\n" if summary else "", encoding="utf-8")
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading