Every OAuth flow ended on a 404, at the last step
The API had been redirecting people to a consent screen that did not exist. Everything before it worked, which is why nobody noticed.
The MCP authorization flow was built carefully. Dynamic client registration, PKCE with S256 pinned, RFC 8707 resource indicators, the RFC 9728 challenge on an unauthenticated request. Every parameter revalidated on the way back, so a hand-crafted form cannot grant a scope the client never registered for.
Then it redirects the person to a page that had never been written.
const handoff = new URL(config.consentUrl); // ${webUrl}/oauth/authorize
handoff.searchParams.set("client_id", validated.client.clientId);
handoff.searchParams.set("redirect_uri", validated.redirectUri);
handoff.searchParams.set("code_challenge", validated.codeChallenge);
handoff.searchParams.set("scope", oauth.formatOAuthScopes(validated.scopes));
response.redirect(303, handoff.toString());Three more URLs were in the same state. `/signin`, where the API sends people after they click a verification link. `/reset`, in every password reset email. And `/privacy` and `/terms`, which this service publishes in its own OAuth metadata for clients to read.
Why nothing caught it
Each of these is a string in one codebase pointing at a route in another. The API is correct in isolation: it builds a URL from configuration and redirects to it. The web app is correct in isolation: it serves the routes it has. Nothing in either one is wrong, and no typechecker crosses that gap because there is nothing to check.
The tests do not cross it either. An API test asserts a 303 with the right `Location`, and that assertion passes whether or not anything is listening at the other end. It is testing that a string was constructed, which is what it should test, and it cannot be the thing that notices.
From the user's side it is invisible in a different way. Somebody adds the MCP server in Claude, a browser window opens, and it shows a 404. What they conclude is that the connector is broken, not that a page is missing, and those produce very different bug reports.
Finding them
Not by clicking around. By listing every URL the API constructs against the web app, and checking each against the routes that exist.
apps/api/src/routes/auth.ts:154 ${webUrl}/signin?verified=1 404
apps/api/src/routes/auth.ts:241 ${webUrl}/reset?token=... 404
apps/api/src/oauth/config.ts:59 ${webUrl}/oauth/authorize 404
apps/api/src/oauth/config.ts:60 ${webUrl}/docs/mcp ok
apps/api/src/oauth/config.ts:61 ${webUrl}/privacy 404
apps/api/src/oauth/config.ts:62 ${webUrl}/terms 404Five out of six. The one that worked, `/docs/mcp`, worked because somebody had written that page for its own sake, not because anything connected it to the metadata that advertises it.
What the consent page has to be careful about
Writing it surfaced a second problem. The session can lapse while the screen is open, so the page has to send the person through sign-in and back, carrying the whole request. That means a `next` parameter, and a `next` parameter read from a query string is an open redirect unless it is checked.
function safeNext(next: string | undefined): string {
if (!next) return "/dashboard";
if (!next.startsWith("/") || next.startsWith("//")) return "/dashboard";
// A backslash resolves like a slash in some browsers, so \evil.com
// escapes the same way //evil.com does.
if (next.includes("\\")) return "/dashboard";
return next;
}A sign-in form that redirects anywhere is worth more to an attacker than most bugs: the link genuinely is on our domain, the password prompt genuinely is ours, and the landing page is theirs. The double-slash case is the one people forget, because `startsWith("/")` looks like it covers it.
The refusal path matters too. Both buttons post to the API, because the client is entitled to an answer either way, and a no that never arrives leaves an application spinning on a screen the person already closed.