No MCP client could complete the handshake, and the bug was one number. Claude Desktop, Claude
Code, claude.ai and ChatGPT all failed at the last step: the user approved the connection, and the
client's callback answered 405 Method Not Allowed (Anthropic) or 400 Bad Request (OpenAI).
Everything looked like the authorization code was being rejected. Nothing was wrong with the code.
The consent screen is an HTML form POST, and NextResponse.redirect() defaults to 307,
which preserves the request method — so the browser re-issued that POST against the client's
callback. Every OAuth callback in existence answers GET only, so every one of them refused it. The
handler now returns 303 See Other on all four of its exits, the one redirect status that
forces the browser to switch to GET. That is the entire point of POST-redirect-GET, and
app/api/integrations/notion/disconnect/route.ts had been doing it correctly all along — this
route just never picked it up.
The consent screen's own error states were dead ends for the same reason. "Pick at least one
space", "your request expired" and "sign in again" redirect to /mcp/consent, which is a page
— and a page route answers GET only, so a 307 got a 405 from Next itself. Anyone who submitted the
form without ticking a space hit a blank error instead of the message written for them.
Why no test caught it: there wasn't one. The route was covered only through its side effects —
what it saved, what it revoked — and never for the shape of its response, so a redirect that was
correct in every respect except its status code passed everything. New tests/mcp-consent.test.ts
asserts the status on all four exits, plus the two properties worth pinning while we're in
there: write is granted only when the box is ticked even if the client asked for it, and a grant
with no spaces is refused rather than issued empty.