CI
Read the traps endpoint from a shell step or a GitHub Actions job, print what it finds, and fail the build only if you ask it to.
CI has no agent in it, so there is no MCP client to run. The plain-text traps endpoint covers this case: one GET per library, no key needed, text/plain back, 204 No Content when nothing is recorded. A job that walks package.json and prints what comes back gives every pull request the same warnings the agent gets, and costs one request per dependency.
The script
#!/usr/bin/env bash
# Print known Wellworn traps for every dependency in package.json.
# Advisory by default; set WELLWORN_FAIL_ON=blocker to make a blocker fail the job.
set -u
manifest=${1:-package.json}
fail_on=${WELLWORN_FAIL_ON:-none}
auth=()
[ -n "${WELLWORN_KEY:-}" ] && auth=(-H "Authorization: Bearer $WELLWORN_KEY")
status=0
while IFS=$'\t' read -r name range; do
out=$(curl -sS -m 6 --get "${auth[@]}" \
--data-urlencode "library=$name" \
--data-urlencode "version=$range" \
-w '\n%{http_code}' \
https://mcp.wellworn.dev/api/traps) || continue
code=${out##*$'\n'}
body=${out%$'\n'*}
case "$code" in
204) continue ;;
429) echo "wellworn: $body"; break ;;
200) ;;
*) echo "wellworn: $name: HTTP $code"; continue ;;
esac
printf '%s\n' "$body"
case "$body" in *blocker*) [ "$fail_on" = blocker ] && status=1 ;; esac
done < <(jq -r '.dependencies // {} | to_entries[] | "\(.key)\t\(.value)"' "$manifest")
exit $statusThe version goes in as written in the manifest, range characters and all. The server coerces ^1.7.3 to 1.7.3 before matching it against each trap's range, so you do not have to read the lockfile to get a useful answer. Nothing is written, nothing is cached, and the loop exits on the first 429 instead of hammering a limit that has already been reached. WELLWORN_KEY is optional: unset, every request goes out anonymous.
What it prints
Run against a manifest pinning better-auth at a version inside a published trap's range, with drizzle-orm and a package the corpus has never heard of:
{
"name": "sample-app",
"dependencies": {
"better-auth": "1.7.1",
"drizzle-orm": "^0.45.0",
"left-pad": "^1.3.0"
}
}TRAPS Better Auth 1.7.1 (1)
[187fc511] blocker >=1.7.0 <1.7.2: organization invitations fail when teams are enabled
pass id for the full fix
TRAPS Drizzle ORM ^0.45.0: none verified for this range.Three requests, two lines of output that matter. left-pad is not in the corpus, so its request answered 204 and the loop skipped it: an unknown library is silent, not an error. A library that is known but clean says so out loud, which is the difference between checked and unchecked. Trap ids are stable, and the lines you get back are whatever is published on the day you run it; https://wellworn.dev/traps/better-auth shows the full fix and the evidence link for each one.
With WELLWORN_FAIL_ON=blocker the same run exits 1, because one of the lines carries blocker. Left unset it exits 0 whatever it prints.
GitHub Actions
jq and curl are both on the hosted Ubuntu runner images, so the job needs no setup step:
name: traps
on: pull_request
jobs:
traps:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Known traps for our dependencies
run: bash scripts/wellworn-traps.shVerified 2026-09-08 against github.com
Add env: { WELLWORN_FAIL_ON: blocker } to the step once the corpus is quiet enough on your dependencies that a new blocker is worth stopping a merge for. Start advisory: a job that fails on its first run teaches people to ignore it.
Limits in CI
Anonymous callers get 60 requests a day per IP and no more than 10 in any 60 seconds. Hosted runners share their addresses widely, so a repository with thirty dependencies can hit the burst inside one job and see limit reached where it expected traps. Two ways out, and they combine: keep the loop to direct dependencies rather than the lockfile, and send a key.
- name: Known traps for our dependencies
env:
WELLWORN_KEY: ${{ secrets.WELLWORN_KEY }}
run: bash scripts/wellworn-traps.shThe script already picks that variable up. A free key is 2,000 calls a month for the whole organization and moves the count off the shared runner IP onto your account. A key that has been revoked or mistyped answers 401 per request and the run stays green, so a rotation gone wrong does not stop a merge. Keys covers creating one; limits has the exact windows and what the 429 body says.
Other manifests
The endpoint takes a library name, not a registry, so the same loop works anywhere you can produce names and versions. Swap the jq line for the manifest you have:
| Manifest | Line that produces name<tab>version |
|---|---|
package.json | jq -r '.dependencies // {} | to_entries[] | "\(.key)\t\(.value)"' package.json |
requirements.txt | sed -n 's/^\([A-Za-z0-9._-]*\)==\(.*\)$/\1\t\2/p' requirements.txt |
composer.json | jq -r '.require // {} | to_entries[] | "\(.key)\t\(.value)"' composer.json |
A name is matched against three columns: the package name, the slug, and the display name, the last one case-insensitively. @modelcontextprotocol/sdk, modelcontextprotocol-sdk and mcp typescript sdk all reach the same entry. Anything unmatched answers 204 and costs one request.