I maintain safari-mcp, an MCP server that lets AI agents drive the real Safari on a Mac, logged-in sessions included. On October 7 an agent opened a blank tab, and its very next command on that tab was refused:
receipt is not valid for this origin
The agent had done everything right. It called safari_new_tab() without a URL, kept the receipt the tool returned, and passed it to safari_navigate. The tab could not be navigated, the receipt could not be rotated, and the tab could not be closed. It sat there in a named Safari profile, and nothing the agent was allowed to do could touch it.
I shipped two releases that day, and each changed one line of code. Both lines were in a guard I had built to be strict on purpose.
What a receipt is
When the safari-mcp Safari extension opens a tab for an agent, it mints an opaque receipt for that tab. Every later command carries the receipt, and before it runs anything the extension checks two things:
- the receipt belongs to this tab, and
- the tab is still on the origin the receipt was minted on.
The second check is the one that matters. An agent works in a browser that also holds the user's email, bank and admin panels. If a page redirects the agent's tab somewhere else, I want the agent's next evaluate refused, not run against whatever is loaded there now. To keep working, the agent has to ask for a fresh receipt on purpose (getReceipt), and the extension issues one only when it can read an http(s) origin, or about:blank, for the tab.
Fail closed. On paper, that is exactly the behavior you want.
The bug was one missing value
This is how the extension derived a tab's origin before the fix:
function _receiptOrigin(rawUrl) {
const raw = String(rawUrl || "");
if (raw === "about:blank") return raw;
try {
const parsed = new URL(raw);
return /^https?:$/.test(parsed.protocol) ? parsed.origin : "";
} catch {
return "";
}
}
A blank tab's receipt is minted on about:blank. I assumed Safari would report a blank tab's URL as about:blank. It doesn't. It leaves url off the tab entirely.
String(undefined || "") is "". new URL("") throws. The function returns "", which in this code means "no origin at all". "about:blank" !== "", so the check refused the tab's own receipt. The escape hatch called the same function, got the same "", and refused as well: Cannot issue a receipt for this tab URL.
The guard did what I built it to do. It just could not tell "this tab moved somewhere else" from "Safari didn't tell me where this tab is", and it treated both as an attack.
The sibling bug, one day earlier
On October 6 a different run left two tabs open in a named profile, both on claude.ai/new. In both cases the page itself had moved the tab, through a redirect or a login bounce. Rotation only works when the new page has an http(s) origin the extension can read; a tab that lands on a page without one, or on a page whose URL Safari doesn't show the extension, can't be rotated.
close_tab was held to the receipt's origin like every other command. The refusal suggested rotating the receipt, which was exactly what these two tabs couldn't do. Nothing could close them.
Same shape as the blank tab: a rule that is right for commands that run code in a page, applied to a command that doesn't.
Two one-line fixes
v2.22.12, the moved tab:
const allowReceiptOriginChange = type === "get_tab_receipt" || type === "close_tab";
Closing a tab runs nothing in the page, so a close now finds its tab the way getReceipt does, by the receipt rather than by the origin. Every other command stays bound to the receipt's origin, and a receipt the extension never issued still closes nothing.
v2.22.13, the blank tab:
if (!raw || raw === "about:blank") return "about:blank";
A tab Safari shows no URL for now counts as blank. A receipt minted on a web origin still takes no page command there, so a tab that went from a real site to a blank page is still refused.
What I got wrong
The origin binding was fine. Two assumptions inside it were not.
I treated "unknown" as "mismatch". A fail-closed check has at least three outcomes: it matches, it doesn't match, or the platform didn't give you enough to decide. My code folded the third into the second, because the function that read the URL returned the same empty string for "no URL reported" and for "an origin I won't trust". Once those two share a value, the guard will sooner or later lock out the owner. The fix was to decide, in code, what a missing URL means for a tab the extension created: it is blank.
I held every command to the same rule. The origin check exists to stop code from running in the wrong page. close_tab runs no code in any page. Binding it to the origin added no protection and took away the only way out. A guard should be as narrow as the harm it prevents, and every guard needs an exit that doesn't depend on the thing it guards.
A test that mocks the extension API with a tidy url: "about:blank" could never have caught the first bug. Both showed up the first time a real Safari profile did something slightly unusual, which is the reason this project drives the real browser in the first place.
If you use safari-mcp's Safari extension, both fixes live in the extension, so they take effect once you rebuild the extension app from 2.22.13 or later (the README's "Installing the Extension" section has the steps).
How do your own guards represent "I couldn't tell"? Does it get its own value, or does it quietly share one with "no"?
Top comments (1)
tr.ee/dev-to