By the end of this lesson you will have an executable gate that reads a proposed set of CMS
changes, judges each one, and refuses the batch with a non-zero exit code before a single page
exists. It does not grade writing. It looks for one shape of failure, already documented.
Here is the shape. Claude cloned a homepage into two new URLs,
/seo-grader and /content-grader, changing only the title tags. Both
recorded zero impressions and zero clicks, and the homepage stayed at position nine or worse
(Search Engine Land, Will Scott, 28 August 2026). Nothing was hallucinated. The instruction
was carried out.
Permissions, then, rather than prompts. The thing worth connecting now exists: Rank Math
published an official MCP server on 27 July 2026. What an authorisation changes is not the drafting,
it is the size of the undo button behind it.
Scope, so nobody wastes an afternoon. This level of the
free SEO course at doctor-seo.net points the models at SEO work and
checks what comes back. Whether an answer engine names you has a level of its own,
GEO and AIO.
What you’ll learn
- Read an exit code as the verdict: a gate that refuses and reports success is worse than no gate.
- Recognise the failure shape that two reviews of two separate pages will both pass.
- Run a pre-flight gate over a batch and read the verdict on each change.
- Price a permission by what reversing it costs, not by what granting it promises.
- What Rank Math’s official MCP reads, what it writes, and which tier gates part of it.
Two URLs, one homepage, and zero impressions
Zero impressions and zero clicks on both of two brand-new URLs, with the homepage they were
copied from still at position nine or worse. Search Engine Land carried that under Will Scott’s
byline on 28 August 2026: an assistant asked for two landing pages produced
/seo-grader and /content-grader, and only a title tag separated either
from the homepage. Cited by publication, author and date, and not linked, because the page could
not be opened.
It happened twice. The duplication recurred independently on a second site, ScryPrice, which is
the difference between an incident and a tendency, and a tendency is what you weigh when you grant a
permission. What the report does not carry is a rate: two sites, one author, no sample size, no
traffic volume, no observation period. A mechanism to design against, not a probability to quote.
Why would a careful reviewer miss it? Because the fault has no location. Open
/seo-grader and it delivers what its title offers; the same holds for
/content-grader. Nothing is wrong on either page and everything is wrong across the
pair, so a review taking one page at a time cannot find it however hard it looks. That is what fixes
the unit of the gate below at the batch.
One more dated account, from a different direction. The author of
ContextBolt’s trial of
23 June 2026 spent a week putting real SEO work through Claude and came out with three
complaints: text over-optimised into keyword stuffing when the instruction was loose,
third-party estimates repeated as though measured, and a recommendation to publish markup for AI
ranking that does not exist. One operator over one week is not a sample, and the piece claims no
more. Its value is the direction it shares with Scott’s case: the words of an instruction get
satisfied and the point of it does not.
Read access and write access are two different grants
Start with the vocabulary, because the grant hides inside it. MCP stands for Model Context
Protocol, and it lets a model place calls into software it does not contain: a WordPress plugin, a
rank-tracking API, a crawler on your own machine. Connecting one adds no capability to Claude; it
opens a door onto capabilities that existed already, and doors have widths. What Claude manages
unaided is set out in
the lesson on what Claude can and cannot
do for SEO.
The gap between the two grants is not a matter of degree. Reading returns a copy of something: an
impression count, a backlink list, a crawl export. Writing changes the site itself: a new URL, a
replaced title tag, a schema block that was not there yesterday. The asymmetry is in the repair. A
misread figure is corrected by looking again; a miswritten URL has to be found, deleted or
redirected, and waited out.
Most of what is connectable today is read-only and holds other people’s data: Semrush documents
its MCP endpoint on
a developer page
last updated 5 August 2026, and the
Ahrefs connector listing carries the
metadata line Added January 2026 and showed 61 tools when checked 20 September 2026. Your own
search data is thinner: Passionfruit, on 28 March 2026,
lists
Google Search Console as community or self-hosted, so that wiring is third-party code. The
minimum-scope posture for it belongs to
the lesson on connecting
Claude to your own search data.
Keyword.com’s State of
AI in SEO 2026 is the one survey cited here that publishes a sample size. From 1 January 2026,
n=97 usable responses, self-selected: 1% describe their work as fully automated, and 57% give
quality not being good enough as the reason for holding back.
| Grant | What it can change | Who sees it if it goes wrong | How it is undone |
|---|---|---|---|
| Reading third-party data (Semrush, Ahrefs) | Nothing on your site | You only | Ask again |
| Reading your own data (Search Console, analytics) | Nothing on your site | You and the report’s readers | Rebuild the report |
| Writing metadata (titles, descriptions, structured data) | How an existing page presents itself | Google, at the next crawl | Restore the previous value, if you kept it |
| Writing content and URLs (create, move, publish) | Which pages exist, and at which addresses | Google, and anyone linking the URL | Delete, redirect or both, and the index lags |
The top two rows cost a few minutes to reverse. The bottom row does not reverse in the
ordinary sense: an address that has been crawled has to be retired, and retiring addresses is
migration work, at whatever size the batch was.
What Rank Math’s official MCP does
Rank Math, the WordPress SEO plugin, shipped its own MCP server, announced in
Rank Math MCP: AI-Powered SEO Automation
for WordPress by Bhanu Ahluwalia, published 27 July 2026 and last updated
16 September 2026. By that announcement the connector authorises over OAuth, runs audits,
reads and writes metadata and schema, and pulls keyword data from Search Console, with some
features reserved for the PRO version. One authorisation therefore produces an editor with
standing access to the title and description of every post on the site, which is exactly what
the product says it is for.
No source cited here publishes a measurement of automated metadata writing, for Rank Math’s
connector or any other route into a CMS: no error rate, no sample size, no count of reverted writes.
A product announcement is a legitimate genre and it is not a study, so the decision rests on numbers
you collect from a small batch before the grant widens. Demand is meanwhile real and unquantified:
Google Autocomplete, harvested 20 September 2026, returned seven variants on this lesson’s phrase,
presence only, with no volume published for any of them and none invented here.
A pre-flight gate that judges the batch, not the page
The gate runs over the plan, never the site. Its input is one local file in which the agent
declares what it wants to create, what the live pages are and what cap you set. It assumes the dry
run, the audit log and the human signature taught in
Claude Code for Technical SEO, and adds what
those do not: every pair in the batch compared, and every proposal compared against your live
pages.
Seven checks, none needing access to your CMS:
- TITLE-ONLY-CLONE: two proposals overlapping at or above the threshold with differing titles. The Search Engine Land shape.
- DUP-BODY-BATCH: the same overlap, identical titles.
- NEAR-DUP-LIVE: a proposal overlapping a page you already have.
- SLUG-TAKEN: a proposed slug a live page holds.
- SLUG-CLASH: two proposals claiming one slug.
- NO-ROLLBACK: a proposal declaring no way back. If it is not written down beforehand, it is not written down.
- OVER-CAP: more changes than the cap you stated.
Thresholds need their provenance said out loud. The 0.90 token overlap is a starting point I
chose, not the result of any study, and the nearest published band, Niko Alho’s 0.78 to 0.85 of
20 May 2026, decides whether two pages deserve an internal link, which does not transfer here.
Calibrate your own: hand-label twenty pairs from your batch and move the threshold until labels and
verdicts agree. The craft underneath sits in
On-Page SEO and Content.
Python 3, standard library only, no network access. Save it as
writegate.py.
#!/usr/bin/env python3
"""writegate.py - refuse a proposed CMS change set before anything is written.
Usage: python3 writegate.py changeset.json
Input is one file in a format defined by this script, cms-changeset/1. The
change set is judged as a batch: the failure this gate exists for is a pair of
pages that each look fine alone.
What it cannot detect: a body field that was itself cut off mid-sentence still
parses, so a short page and a truncated page look the same here; and if the
exporter wrote declared_changes after truncating, the count agrees and the loss
is invisible. Truncation is found by brace and bracket balance, so a cut whose
braces happen to balance is reported as a wrong document rather than as a
truncation. Duplicate keys in one object resolve silently to the last value,
which the standard-library parser does not report. Overlap is token overlap, so
two pages rewritten into different words around the same empty claim both pass.
Exit codes: 0 pass, 1 findings, 2 command line, 3 unreadable, 4 empty, 5 HTML
document, 6 truncated, 7 invalid JSON, 8 not a cms-changeset/1 document,
9 unusable change record, 10 unusable live-page record, 11 unusable url_cap,
12 no changes declared, 13 internal error.
"""
import json
import re
import sys
from itertools import combinations
OVERLAP = 0.90 # my starting point, not a published figure
FIELDS = ("slug", "title", "body", "rollback")
def say(text):
sys.stdout.write(text + "\n")
def stop(code, text):
say("input error: " + text)
sys.exit(code)
def tokens(text):
return {t for t in re.findall(r"[a-z0-9]+", text.lower()) if len(t) > 2}
def overlap(a, b):
sa, sb = tokens(a), tokens(b)
if not sa or not sb:
return 0.0
return len(sa & sb) / len(sa | sb)
def unclosed(text):
"""True when braces or brackets are left open, or a string is left open."""
depth, in_string, escaped = 0, False, False
for char in text:
if in_string:
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == '"':
in_string = False
continue
if char == '"':
in_string = True
elif char in "{[":
depth += 1
elif char in "}]":
depth -= 1
return in_string or depth > 0
def text_field(holder, key):
value = holder.get(key)
return value if isinstance(value, str) and value.strip() else None
def load(path):
"""Return the parsed document, or exit with the code for what went wrong."""
try:
with open(path, encoding="utf-8-sig", errors="replace") as handle:
raw = handle.read()
except OSError as err:
stop(3, "cannot read %s (%s)" % (path, err))
body = raw.strip()
if not body:
stop(4, "%s is empty" % path)
if body[0] == "<":
stop(5, "%s is an HTML document, not a change set" % path)
if body[0] != "{":
stop(8, "%s is not a cms-changeset/1 document" % path)
try:
doc = json.loads(body)
except ValueError as err:
if unclosed(body):
stop(6, "%s is truncated (%s)" % (path, err))
stop(7, "%s is not valid JSON (%s)" % (path, err))
if doc.get("format") != "cms-changeset/1" or not isinstance(doc.get("changes"), list):
stop(8, "%s is not a cms-changeset/1 document" % path)
declared = doc.get("declared_changes")
if "declared_changes" in doc and (isinstance(declared, bool)
or not isinstance(declared, int)):
stop(8, "%s is not a cms-changeset/1 document: declared_changes is not an integer"
% path)
if isinstance(declared, int) and declared > len(doc["changes"]):
stop(6, "%s is truncated, %d changes declared and %d present"
% (path, declared, len(doc["changes"])))
if not doc["changes"]:
stop(12, "%s declares no changes" % path)
cap = doc.get("url_cap")
if "url_cap" in doc and (isinstance(cap, bool) or not isinstance(cap, int) or cap < 1):
stop(11, "url_cap in %s is not a whole number of one or more" % path)
for pos, change in enumerate(doc["changes"], 1):
if not isinstance(change, dict):
stop(9, "change %d in %s is not an object" % (pos, path))
for field in FIELDS:
if field not in change or not isinstance(change[field], str):
stop(9, "change %d in %s has no usable %s" % (pos, path, field))
for field in ("slug", "body"):
if not change[field].strip():
stop(9, "change %d in %s has an empty %s" % (pos, path, field))
live = doc.get("live_pages", [])
if not isinstance(live, list):
stop(10, "live_pages in %s is not a list" % path)
for pos, page in enumerate(live, 1):
if not isinstance(page, dict):
stop(10, "live page %d in %s is not an object" % (pos, path))
if not text_field(page, "slug"):
stop(10, "live page %d in %s has no usable slug" % (pos, path))
if not text_field(page, "text"):
stop(10, "live page %d in %s has no usable text" % (pos, path))
return doc
def judge(doc):
changes = doc["changes"]
live = [(p["slug"], p["text"]) for p in doc.get("live_pages", [])]
cap = doc.get("url_cap", len(changes))
found = [[] for _ in changes]
batch = []
for index, change in enumerate(changes):
if not change["rollback"].strip():
found[index].append(("NO-ROLLBACK", "declares no way back"))
for live_slug, live_text in live:
if change["slug"] == live_slug:
found[index].append(("SLUG-TAKEN", "a live page already has this slug"))
score = overlap(change["body"], live_text)
if score >= OVERLAP:
found[index].append(
("NEAR-DUP-LIVE", "overlaps live /%s at %.2f" % (live_slug, score)))
groups = {}
for index, change in enumerate(changes):
groups.setdefault(change["slug"], []).append(index)
for slug, members in sorted(groups.items()):
if len(members) > 1:
for index in members:
found[index].append(
("SLUG-CLASH", "%d changes claim /%s" % (len(members), slug)))
for (ia, a), (ib, b) in combinations(list(enumerate(changes)), 2):
score = overlap(a["body"], b["body"])
if score >= OVERLAP:
label = "TITLE-ONLY-CLONE" if a["title"] != b["title"] else "DUP-BODY-BATCH"
found[ia].append((label, "overlaps change %d at %.2f" % (ib + 1, score)))
found[ib].append((label, "overlaps change %d at %.2f" % (ia + 1, score)))
if len(changes) > cap:
batch.append(("OVER-CAP", "%d changes against a stated cap of %d" % (len(changes), cap)))
return found, batch
def run(argv):
if len(argv) != 2:
say("usage: writegate.py changeset.json")
return 2
doc = load(argv[1])
found, batch = judge(doc)
total = 0
say("writegate: %d proposed changes, %d live page(s) supplied"
% (len(doc["changes"]), len(doc.get("live_pages", []))))
for index, change in enumerate(doc["changes"]):
if not found[index]:
say(" pass change %d /%s" % (index + 1, change["slug"]))
continue
for label, detail in found[index]:
total += 1
say(" REFUSE change %d /%s %s: %s" % (index + 1, change["slug"], label, detail))
for label, detail in batch:
total += 1
say(" REFUSE batch %s: %s" % (label, detail))
if total:
say("verdict: %d finding(s). Nothing is written." % total)
return 1
say("verdict: no findings. The batch can go to a human.")
return 0
if __name__ == "__main__":
try:
sys.exit(run(sys.argv))
except SystemExit:
raise
except Exception as err: # never leave exit 1 to mean this
say("internal error: %s: %s" % (type(err).__name__, err))
sys.exit(13)
The generator writes every file the runs below use. Every slug, title and body in it is
a synthetic fixture invented for this lesson: no real export, plugin field, CMS response or API
payload is reproduced.
#!/usr/bin/env python3
"""make_fixtures.py - write every file the writegate.py runs in this lesson use.
synthetic fixture: every slug, title and body below is invented for this lesson.
No real site, export, plugin field, CMS response or API payload is reproduced.
"""
import json
TOOL = ("Paste a list of page titles and the checker reports which ones run past "
"sixty characters, which repeat the same keyword twice and which get cut "
"off in the result. The report arrives in under a minute and exports to CSV.")
CRAWL = ("Crawl budget is the number of pages a search engine will fetch from a site "
"in a given period. This page explains what wastes it on a shop with faceted "
"navigation, how to measure the waste in server logs, and which settings move it.")
HOPS = ("Give the checker a list of addresses and it follows every hop to the last one, "
"flags any chain longer than a single step and marks the loops. Nothing on the "
"server is changed: the output is a table you read before deciding anything.")
NOTES = ("Three questions decide whether a template earns a page of its own, and this "
"note works through each of them with a worked example from a small catalogue "
"rather than from a spreadsheet of city names.")
TITLES = ("Upload titles and get back the ones a result will truncate, with the character "
"count beside each and a suggested cut that keeps the brand at the end. "
"Everything stays in the browser and nothing is stored.")
LIVE = [{"slug": "title-checker", "text": TOOL}, {"slug": "crawl-budget", "text": CRAWL}]
ROLLBACK = "delete the page and return a 410"
DIRTY = {
"format": "cms-changeset/1",
"declared_changes": 4,
"url_cap": 3,
"live_pages": LIVE,
"changes": [
{"slug": "title-length-checker", "title": "Title length checker",
"rollback": ROLLBACK, "body": TOOL},
{"slug": "meta-length-checker", "title": "Meta description length checker",
"rollback": ROLLBACK, "body": TOOL},
{"slug": "crawl-budget", "title": "Crawl budget, explained",
"rollback": "", "body": NOTES},
{"slug": "redirect-chain-finder", "title": "Redirect chain finder",
"rollback": ROLLBACK, "body": HOPS},
],
}
FIXED = {
"format": "cms-changeset/1",
"declared_changes": 3,
"url_cap": 3,
"live_pages": LIVE,
"changes": [
{"slug": "title-length-checker", "title": "Title length checker",
"rollback": ROLLBACK, "body": TITLES},
{"slug": "template-test-notes", "title": "Three questions before a template ships",
"rollback": ROLLBACK, "body": NOTES},
{"slug": "redirect-chain-finder", "title": "Redirect chain finder",
"rollback": ROLLBACK, "body": HOPS},
],
}
CLASH = {
"format": "cms-changeset/1",
"declared_changes": 3,
"url_cap": 3,
"live_pages": [{"slug": "crawl-budget", "text": CRAWL}],
"changes": [
{"slug": "template-test-notes", "title": "Three questions before a template ships",
"rollback": ROLLBACK, "body": NOTES},
{"slug": "template-test-notes", "title": "Three questions before a template ships",
"rollback": ROLLBACK, "body": NOTES},
{"slug": "redirect-chain-finder", "title": "Redirect chain finder",
"rollback": ROLLBACK, "body": HOPS},
],
}
BADRECORD = json.loads(json.dumps(FIXED))
del BADRECORD["changes"][1]["rollback"]
BADLIVE = json.loads(json.dumps(FIXED))
del BADLIVE["live_pages"][1]["text"]
BADCAP = json.loads(json.dumps(FIXED))
BADCAP["url_cap"] = -1
NOCHANGES = json.loads(json.dumps(FIXED))
NOCHANGES["declared_changes"] = 0
NOCHANGES["changes"] = []
def write(name, text):
with open(name, "w", encoding="utf-8") as handle:
handle.write(text)
print("wrote %s" % name)
def main():
dirty = json.dumps(DIRTY, indent=2) + "\n"
write("changeset.json", dirty)
write("changeset-fixed.json", json.dumps(FIXED, indent=2) + "\n")
write("changeset-clash.json", json.dumps(CLASH, indent=2) + "\n")
write("changeset-badrecord.json", json.dumps(BADRECORD, indent=2) + "\n")
write("changeset-badlive.json", json.dumps(BADLIVE, indent=2) + "\n")
write("changeset-badcap.json", json.dumps(BADCAP, indent=2) + "\n")
write("changeset-nochanges.json", json.dumps(NOCHANGES, indent=2) + "\n")
write("changeset-empty.json", "")
# cut inside a string value
write("changeset-truncated.json", dirty[:len(dirty) // 2])
# cut on a record boundary, so the last byte is a closing brace
cut = dirty.index("\n },\n") + len("\n }")
write("changeset-cutrecord.json", dirty[:cut])
write("changeset-invalid.json",
'{\n "format": "cms-changeset/1",\n "changes": [{,}]\n}\n')
write("changeset-wrongformat.csv",
"slug,title,body\ntitle-length-checker,Title length checker,see above\n")
write("changeset-interstitial.html",
"<!doctype html>\n<title>One moment, please...</title>\n"
"<p>Checking your browser.</p>\n")
if __name__ == "__main__":
main()
The first change set reproduces the documented failure on purpose, plus three other
problems, against a stated cap of three:
$ python3 make_fixtures.py
wrote changeset.json
wrote changeset-fixed.json
wrote changeset-clash.json
wrote changeset-badrecord.json
wrote changeset-badlive.json
wrote changeset-badcap.json
wrote changeset-nochanges.json
wrote changeset-empty.json
wrote changeset-truncated.json
wrote changeset-cutrecord.json
wrote changeset-invalid.json
wrote changeset-wrongformat.csv
wrote changeset-interstitial.html
$ python3 writegate.py changeset.json
writegate: 4 proposed changes, 2 live page(s) supplied
REFUSE change 1 /title-length-checker NEAR-DUP-LIVE: overlaps live /title-checker at 1.00
REFUSE change 1 /title-length-checker TITLE-ONLY-CLONE: overlaps change 2 at 1.00
REFUSE change 2 /meta-length-checker NEAR-DUP-LIVE: overlaps live /title-checker at 1.00
REFUSE change 2 /meta-length-checker TITLE-ONLY-CLONE: overlaps change 1 at 1.00
REFUSE change 3 /crawl-budget NO-ROLLBACK: declares no way back
REFUSE change 3 /crawl-budget SLUG-TAKEN: a live page already has this slug
pass change 4 /redirect-chain-finder
REFUSE batch OVER-CAP: 4 changes against a stated cap of 3
verdict: 7 finding(s). Nothing is written.
$ echo $?
1
Read the per-change lines, not just the verdict: three proposals carried findings, the fourth
passed, and one page in the batch was never the problem. Four edits clear it, and the corrected file
ships too, so this verdict reproduces with no editing:
$ python3 writegate.py changeset-fixed.json
writegate: 3 proposed changes, 2 live page(s) supplied
pass change 1 /title-length-checker
pass change 2 /template-test-notes
pass change 3 /redirect-chain-finder
verdict: no findings. The batch can go to a human.
$ echo $?
0
SLUG-CLASH needs two proposals on one slug and DUP-BODY-BATCH needs two identical titles, so
neither fires in the batches above. A third fixture gives them both:
$ python3 writegate.py changeset-clash.json
writegate: 3 proposed changes, 1 live page(s) supplied
REFUSE change 1 /template-test-notes SLUG-CLASH: 2 changes claim /template-test-notes
REFUSE change 1 /template-test-notes DUP-BODY-BATCH: overlaps change 2 at 1.00
REFUSE change 2 /template-test-notes SLUG-CLASH: 2 changes claim /template-test-notes
REFUSE change 2 /template-test-notes DUP-BODY-BATCH: overlaps change 1 at 1.00
pass change 3 /redirect-chain-finder
verdict: 4 finding(s). Nothing is written.
$ echo $?
1
Fourteen exit codes, and why a refusal must never be zero
A gate that refuses a batch and exits 0 has told its caller that everything went well, and the
step chained behind it runs as though a human had approved something. The refusal here exits
1. Every other cause has its own code, and no two causes share one.
| Code | Cause |
|---|---|
| 0 | Every proposed change passed |
| 1 | At least one finding; nothing is written |
| 2 | Wrong command line |
| 3 | The file could not be opened |
| 4 | The file is empty |
| 5 | The file is an HTML document, not data |
| 6 | The file is truncated |
| 7 | The file is not valid JSON, and not truncated |
| 8 | The file is not a cms-changeset/1 document |
| 9 | A change record is unusable |
| 10 | A live-page record is unusable |
| 11 | url_cap is not a whole number of one or more |
| 12 | The change set declares no changes |
| 13 | The script itself failed unexpectedly |
Codes 3 to 12 fire before any change is judged, which is their point: a bad input must never
arrive dressed as a finding about your site. Code 5 is there because a file pulled over HTTP can
arrive as a bot-protection interstitial under a 200 status, which a parser checking the status code
alone accepts. Code 6 goes by brace balance rather than the last byte, because an export cut at the
end of a record ends on a closing brace. Code 11 rejects a cap that is negative, zero or a boolean,
each of which would otherwise print as a verdict. Code 13 is the backstop, so an unanticipated
exception cannot borrow the code that means a finding.
Eleven runs: ten files from the generator plus one path that does not exist, which is the test.
Code 6 appears twice because truncation has two shapes, a cut inside a string and a cut on a record
boundary, not because two causes share a code. The message in brackets is the interpreter’s wording,
not the script’s, so different words there are not a discrepancy:
$ python3 writegate.py changeset-empty.json
input error: changeset-empty.json is empty
$ echo $?
4
$ python3 writegate.py changeset-interstitial.html
input error: changeset-interstitial.html is an HTML document, not a change set
$ echo $?
5
$ python3 writegate.py changeset-wrongformat.csv
input error: changeset-wrongformat.csv is not a cms-changeset/1 document
$ echo $?
8
$ python3 writegate.py changeset-invalid.json
input error: changeset-invalid.json is not valid JSON (Expecting property name enclosed in double quotes: line 3 column 16 (char 48))
$ echo $?
7
$ python3 writegate.py changeset-truncated.json
input error: changeset-truncated.json is truncated (Unterminated string starting at: line 23 column 15 (char 1082))
$ echo $?
6
$ python3 writegate.py changeset-cutrecord.json
input error: changeset-cutrecord.json is truncated (Expecting ',' delimiter: line 9 column 6 (char 370))
$ echo $?
6
$ python3 writegate.py changeset-nochanges.json
input error: changeset-nochanges.json declares no changes
$ echo $?
12
$ python3 writegate.py changeset-badrecord.json
input error: change 2 in changeset-badrecord.json has no usable rollback
$ echo $?
9
$ python3 writegate.py changeset-badlive.json
input error: live page 2 in changeset-badlive.json has no usable text
$ echo $?
10
$ python3 writegate.py changeset-badcap.json
input error: url_cap in changeset-badcap.json is not a whole number of one or more
$ echo $?
11
$ python3 writegate.py changeset-missing.json
input error: cannot read changeset-missing.json ([Errno 2] No such file or directory: 'changeset-missing.json')
$ echo $?
3
Before chaining this to anything, confirm the refusal propagates: run
python3 writegate.py changeset.json; echo $? and check the last line is 1. A printed
message is not an exit status, and only one of the two reaches the next step.
What the gate cannot detect deserves saying as plainly as what it can. A body cut off
mid-sentence still parses, so a short page and a truncated page look identical to it; and if the
exporter wrote the declared count after truncating, the count agrees and the loss is invisible.
Overlap is token overlap, so two pages rewritten into different words around the same empty claim
both pass. A cut whose braces happen to balance is filed as a wrong document rather than as a
truncation. And duplicate keys in one object resolve silently to the last value, which the
standard-library parser does not report, so a declared cap can be overwritten by a second copy of
itself without a word.
A dashboard setting does not bind a connector
No source cited here measures whether WordPress’s own limits hold when a write arrives through a
connector rather than a browser: who may publish, what stays a draft, which user ends up on the
revision. One adjacent record exists, and it concerns a crawler rather than a CMS. Crawls launched
through MCP did not inherit the interface’s speed setting: 1 URL/sec was configured and 500 URLs
completed in 20 seconds
(Rich Voller,
26 May 2026), in a practitioner’s report that publishes no sample size. A crawler is not
WordPress, so that settles nothing about your install. It is a reason to treat the dashboard’s
rules as a claim rather than a guarantee.
Testing the claim takes one page. Have the connector write a single draft to something
disposable, then read the revision history for the author it recorded and the status it left
behind. If either is not what you asked for, the grant is wider than you thought, and the
discovery cost you a page nobody reads.
Where practitioners disagree about pages at scale
Write access plus a model is how pages get generated in quantity, so the policy question
arrives whether or not you invited it, and this page does not settle it. Google’s Search spam
policies, last updated 28 August 2026, define scaled content abuse as many pages generated for the
primary purpose of manipulating search rankings and not helping users, no matter how they were
created. Google has separately stated that AI use is not itself a violation. No threshold appears
anywhere in the policy: no page count, no rate, no proportion of a site.
From that text the profession splits. One camp reads the operative clause as putting the whole
test on purpose, so a verified, distinct, useful page is not an infringement at any count; its best
evidence is the clause’s own wording, which turns on what the pages were generated for. The other
notes that the same sentence says many pages; its best evidence is that the policy gives an
outside observer no way to establish purpose, while page count is on the record either way.
Neither camp can point at a threshold, because none is published, and no controlled study
isolates the effect of production method on outcomes. So no verdict here. What this page offers is a
cap you set yourself and a per-change verdict, so the argument happens with your own number in view.
The boundary itself is argued in
Grey Hat, Black Hat and Parasite SEO and in
Advanced SEO Strategy.
The nearest practical guidance among the sources cited here is unlinked: CC for SEO,
6 March 2026, asking for four to six differentiating fields per entity, fingerprint-based duplicate
detection, cohort-level Search Console monitoring, 100 to 500 pages as a start and a 60-day
observation window, and warning that pages differing only by city name underperform because Google
evaluates them in aggregate. One practitioner, not a standard.
Common mistakes
- A gate that refuses and exits 0. The most expensive mistake here, because it
looks like success and a pipeline believes it. The fix: check the exit status of a
refusal before chaining the gate to anything. - Letting a crash count as a finding. An uncaught exception exits 1 in Python,
the code a refusal uses, so a bad export arrives looking like a verdict about your pages.
The fix: validate every field the judging code touches, give input faults their own
codes, and keep a catch-all on a code of its own. - Shipping a threshold you never calibrated. A borrowed number decides which
pairs get flagged, and nobody has published one for this task. The fix: hand-label
twenty pairs from your own batch, move the threshold until labels and verdicts agree, and record what
you chose. - Reviewing page by page instead of reviewing the batch. The Search Engine Land
failure is invisible in either page alone. The fix: compare every pair before
anything is written, and compare each proposal against the pages you already have.
The short version
- Claude cloned a homepage into two URLs changing only the title tags: zero impressions and zero clicks on both, the homepage at position nine or worse, and the same duplication on a second site, ScryPrice (Search Engine Land, Will Scott, 28 August 2026). Two cases, one author, no sample size.
- Rank Math shipped an official MCP on 27 July 2026, last updated 16 September 2026: OAuth, audits, metadata and schema read and written, Search Console keyword data, some features PRO-only. No source cited here publishes an error rate for automated CMS metadata writing, so the only number available is one you collect.
- A refusal that exits 0 reports success to whatever called it. Fourteen causes, fourteen codes, and the refusal is 1.
- Google’s spam policies, last updated 28 August 2026, test purpose rather than production method, and publish no threshold. This page gives no verdict on where the line sits.
- The cloning failure is invisible to a page-by-page review, because the defect is a relation between two pages. Review the batch.
- Rich Voller documented one case, on 26 May 2026, of a speed setting not being inherited by a crawl launched through MCP, and it is not about WordPress. It makes the CMS equivalent something to check.
- Validate every field before judging any row, or an uncaught exception will exit 1 and read as a finding about your pages.
Frequently asked questions
Which permission should I grant first?
A read-only one, and not on the CMS. When writing does come, make the worst outcome reversible
before you make it possible: metadata before content, drafts before published pages, one page before
a batch. Nobody has published an error rate for automated CMS writing, so staging does what evidence
cannot.
Where do the 0.90 threshold and the sample files come from?
The threshold is one I chose, not a published figure; the nearest published band is Niko Alho’s
0.78 to 0.85 of 20 May 2026, for a different task. Every change set, slug, title and body in the runs
above is a synthetic fixture written by the generator here. None reproduces a real export or a field
name from a real CMS, which is why the input format is one the script defines rather than one it
claims to have found.
Can I run the gate over pages an agent has already published?
Yes: export the pages it created and feed them as the plan. The duplicate-body and title-only
checks behave the same on published pages and return candidates for merging or redirecting. What
they cannot tell you is what the duplication cost while it was live.
Does a clean verdict mean my batch is within Google’s policy?
No, and it cannot. Google’s spam policies, last updated 28 August 2026, test the purpose behind a
set of pages and publish no count, rate or proportion, so no script returns that verdict. A clean run
means no near-duplicate pair, no slug collision, no undeclared rollback and no more URLs than your
cap. Purpose stays with whoever signs it off.
Sources
Linked, in order of first mention above, each checked against the page it points at:
- ContextBolt, author given only as David, Claude SEO Experiment: A Week of Running My Real SEO — 23 June 2026. One practitioner, one week, no sample size.
- Semrush, Semrush MCP documentation — last updated 5 August 2026.
- Anthropic, Ahrefs connector listing — metadata line Added January 2026; 61 tools, checked 20 September 2026.
- Dewang Mishra, Passionfruit, MCP Connectors for Marketing — 28 March 2026.
- Keyword.com, State of AI in SEO 2026 — 1 January 2026, n=97 usable responses, self-selected.
- Rank Math, Bhanu Ahluwalia, Rank Math MCP: AI-Powered SEO Automation for WordPress — 27 July 2026, last updated 16 September 2026.
- Rich Voller, Screaming Frog v24 MCP: The Complete Agency Guide — 26 May 2026, updated 12 June 2026. No sample size published.
Cited without a link because the page could not be opened, named and dated, no repost
substituted:
- Search Engine Land, Will Scott, 28 August 2026 — the homepage cloned into
/seo-graderand/content-grader, zero impressions and zero clicks on both, the homepage at position nine or worse, and a recurrence on ScryPrice. - Google Search Central, Search spam policies — last updated 28 August 2026; scaled content abuse defined by purpose, no threshold published.
- CC for SEO, Programmatic SEO with Claude Code, 6 March 2026 — the protocol summarised above.
- Niko Alho, 20 May 2026 — a 0.78 to 0.85 similarity band for automated internal linking.
- Google Autocomplete, harvested 20 September 2026 — seven variants on this lesson’s phrase; presence only.
Continue the course
This page belongs to AI for SEO: Using the Models as
Tools, inside the free SEO course at doctor-seo.net. Read
Claude Code for Technical SEO for the
execution side of an agent write, and
Connecting Claude to Your Own Search
Data for the monitoring that follows a batch. Repairing a bad write is
technical SEO.