app/lib/mcp/door.ts
322 lines and 20 functions in DrDustinEdwards/carrel, at commit d5d5e82 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| mcpOrigin | 44 to 47 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| isMcpHost | 50 to 52 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| text | 61 to 66 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| redirect | 68 to 73 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| redirectError | 76 to 83 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| liveness | 90 to 94 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| escape | 96 to 96 | 1 | 67% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:96> | 96 to 96 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| clientLabel | 99 to 107 | 3 | 50% | OkCRAP 4.1 | No data: mutation testing has not run on this repo yet |
| consentPage | 114 to 150 | 5 | 100% | OkCRAP 5 | No data: mutation testing has not run on this repo yet |
| authorizeGet | 152 to 163 | 3 | 91% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| authorizePost | 165 to 184 | 5 | 93% | WatchCRAP 5 | No data: mutation testing has not run on this repo yet |
| callback | 186 to 219 | 6 | 96% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| <anonymous:207> | 207 to 207 | 1 | 50% | OkCRAP 1.1 | No data: mutation testing has not run on this repo yet |
| fetch | 223 to 245 | 15 | 80% | WatchCRAP 16.8 | No data: mutation testing has not run on this repo yet |
| fetch | 254 to 273 | 10 | 100% | WatchCRAP 10 | No data: mutation testing has not run on this repo yet |
| agentHandler | 283 to 294 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| providerFor | 302 to 333 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| clientRegistrationCallback | 328 to 328 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| aiDoor | 336 to 348 | 7 | 100% | WatchCRAP 7 | No data: mutation testing has not run on this repo yet |
Imports
It imports app/lib/agent-keys.server.ts, app/lib/deployed.ts, app/lib/mcp/registration.ts, app/lib/mcp/scopes.ts, app/lib/mcp/server.ts, app/lib/people.server.ts. Imported by workers/app.ts.
Source
// The AI door: everything on carrel-mcp.dustinedwards.info (design decision 7, corrected 2026-09-27).
//
// That hostname is exempt from Carrel's Worker-level Access (a hostname Bypass app, setup in the PR),
// because an MCP client must reach the OAuth endpoints before anyone has signed in. So nothing here
// trusts Access's own header. Instead this door is an OAuth 2.1 authorization server of its own
// (@cloudflare/workers-oauth-provider): CIMD for Claude, Claude Code and ChatGPT, a tightened
// registration for Grok and Gemini (registration.ts), and the person signs in upstream through
// Cloudflare Access for SaaS (site-runtime's access-login). The verified email must belong to an
// active person in Carrel, checked when the grant is made and again on every request.
//
// Every request through this door is an AI session, told apart by the hostname it arrived on, never
// by email. The pages are never served here, and this door is never served on the browser's host.
//
// Routes on this host, outermost first:
// /mcp a named agent key (agent-keys.server.ts) goes straight to agentHandler;
// anything else: the provider checks the bearer token, then apiHandler
// finds the person and hands the request to server.ts
// /authorize /callback the consent page and the Access sign-in, below
// /register tightened registration, rate limited here, then the provider
// /token, /.well-known/* served by the provider
// /health liveness, and the deployed commit for the deploy's live gate
// anything else 404
import OAuthProvider, { AuthorizationError, CimdFetchError, type AuthRequest, type ClientInfo, type OAuthHelpers } from "@cloudflare/workers-oauth-provider";
import { agentClient, agentPrincipal } from "~/lib/agent-keys.server";
import { deployedSha } from "~/lib/deployed";
import { findAgentViewer, findViewer } from "~/lib/people.server";
import { accessSaas, authorizationUrl, finishSignIn, newSignIn } from "@dustinedwards/site-runtime/access-login";
import { allowedRedirects, checkRegistration, REGISTER_PATH, registrationLimited } from "./registration";
import { AGENT_SCOPES, effectiveScopes, grantScopes, SCOPE_WRITE, SCOPES_SUPPORTED } from "./scopes";
import { carrelOrigin, handleMcp, MCP_ROUTE } from "./server";
/** What a grant stores, encrypted, and hands to every request made with its tokens. */
type DoorProps = { personId: number; email: string; client: string; clientId: string };
type DoorEnv = Env & { OAUTH_PROVIDER: OAuthHelpers };
/**
* The AI door's origin. Read loosely, like CARREL_ORIGIN, because a wrangler.jsonc from before this
* change has no CARREL_MCP_ORIGIN and its generated types would not either.
*/
export function mcpOrigin(env: Env): string {
const configured = (env as { CARREL_MCP_ORIGIN?: string }).CARREL_MCP_ORIGIN?.trim();
return (configured || "https://carrel-mcp.dustinedwards.info").replace(/\/+$/, "");
}
/** Whether a request arrived on the AI door's hostname. The only thing that decides the door. */
export function isMcpHost(request: Request, env: Env): boolean {
return new URL(request.url).host.toLowerCase() === new URL(mcpOrigin(env)).host.toLowerCase();
}
// Caching and indexing only. The security headers are workers/security-headers.ts's, applied by
// workers/app.ts to every response on this host.
const PRIVATE_HEADERS: Record<string, string> = {
"Cache-Control": "no-store",
"X-Robots-Tag": "noindex, nofollow",
};
function text(body: string, status: number, headers?: Headers): Response {
const out = new Headers(headers);
out.set("Content-Type", "text/plain; charset=utf-8");
for (const [name, value] of Object.entries(PRIVATE_HEADERS)) out.set(name, value);
return new Response(body, { status, headers: out });
}
function redirect(location: string, headers?: Headers): Response {
const out = new Headers(headers);
out.set("Location", location);
out.set("Cache-Control", "no-store");
return new Response(null, { status: 302, headers: out });
}
/** The OAuth error answer to the client, once its redirect URI is known good. */
function redirectError(request: AuthRequest, description: string, headers?: Headers): Response {
const url = new URL(request.redirectUri);
url.searchParams.set("error", "access_denied");
url.searchParams.set("error_description", description);
url.searchParams.set("state", request.state);
if (request.issuer) url.searchParams.set("iss", request.issuer);
return redirect(url.href, headers);
}
/**
* Liveness on the AI door, the one Carrel host outside Access, so CI can read it before anyone signs in.
* The live gate polls it until `sha` is the commit it deployed. It says nothing else: no backup age, no
* table, no error text (the backup health stays on the browser host's /health, behind Access).
*/
function liveness(env: Env): Response {
const out = new Headers({ "Content-Type": "application/json; charset=utf-8" });
for (const [name, value] of Object.entries(PRIVATE_HEADERS)) out.set(name, value);
return new Response(JSON.stringify({ ok: true, sha: deployedSha(env) }), { status: 200, headers: out });
}
const escape = (value: string) => value.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
/** The name a client is credited by: its CIMD client_name, or else the host that publishes it. */
function clientLabel(client: ClientInfo | null, clientId: string): string {
const name = client?.clientName?.trim();
if (name) return name.slice(0, 100);
try {
return new URL(clientId).hostname;
} catch {
return "an unnamed AI client";
}
}
/**
* The consent page, shown for every authorization of every client: nothing remembers an approval.
* Consent has to be an act, so it is a real form. Everything from the client is escaped: a CIMD
* document's name is chosen by whoever serves it, and a registered client's by whoever registered it.
*/
function consentPage(client: ClientInfo | null, request: AuthRequest, handle: string): string {
const name = escape(clientLabel(client, request.clientId));
const redirectHost = new URL(request.redirectUri).hostname;
const local = /^(localhost|127(\.\d{1,3}){3}|\[::1\])$/.test(redirectHost);
const publisher = request.clientId.startsWith("https://")
? `Published by <strong>${escape(new URL(request.clientId).hostname)}</strong>.`
: "This app registered itself with Carrel, so its name is its own claim, not checked.";
const writes = grantScopes(request.scope).includes(SCOPE_WRITE);
const asks = writes
? "It asks to read, save AI drafts beside yours, and flag problems. You can allow reading only."
: "It asks to read only. It will not be able to save drafts or flag problems.";
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>Allow ${name} to use Carrel?</title>
<style>
:root { color-scheme: light dark; }
body { font: 17px/1.6 Georgia, "Iowan Old Style", serif; max-width: 34rem; margin: 4rem auto; padding: 0 1.25rem; }
h1 { font-size: 1.4rem; line-height: 1.3; }
.fine { font-size: .9rem; opacity: .8; }
button { font: inherit; padding: .55rem 1.1rem; border-radius: .35rem; cursor: pointer; margin-right: .5rem; }
</style>
</head><body>
<h1>Allow ${name} to use Carrel as you?</h1>
<p>${publisher} Its access will be sent to <strong>${escape(redirectHost)}</strong>.</p>
${local ? "<p><strong>This sends access to an app on your computer.</strong> Continue only if you just started signing in from it.</p>" : ""}
<p>${asks} Either way it can do only what your role in Carrel allows. Publishing is for Dustin's own sessions only, and never while a flag is open.</p>
<p class="fine">Next you sign in with Cloudflare Access. Only people Carrel already knows are let in.</p>
<form method="post">
<input type="hidden" name="handle" value="${escape(handle)}">
<button name="decision" value="approve">Continue to sign in</button>
${writes ? '<button name="decision" value="read-only">Allow reading only</button>' : ""}
<button name="decision" value="deny">Deny</button>
</form>
</body></html>`;
}
async function authorizeGet(request: Request, env: DoorEnv): Promise<Response> {
if (!accessSaas(env)) return text("Carrel's AI door is not configured: ACCESS_SAAS_CLIENT_ID or ACCESS_SAAS_CLIENT_SECRET is unset. Not configured means not open.", 503);
const oauth = env.OAUTH_PROVIDER;
const authRequest = await oauth.parseAuthRequest(request);
const client = await oauth.lookupClient(authRequest.clientId);
if (!client) return text("Unknown OAuth client.", 400);
const consent = await oauth.beginConsent(authRequest);
consent.headers.set("Content-Type", "text/html; charset=utf-8");
// no-transform keeps Cloudflare from injecting its Web Analytics beacon into this page.
consent.headers.set("Cache-Control", "no-store, no-transform");
return new Response(consentPage(client, authRequest, consent.handle), { headers: consent.headers });
}
async function authorizePost(request: Request, env: DoorEnv): Promise<Response> {
const saas = accessSaas(env);
if (!saas) return text("Carrel's AI door is not configured. Not configured means not open.", 503);
const oauth = env.OAUTH_PROVIDER;
const form = await request.formData();
const handle = String(form.get("handle") ?? "");
const decision = form.get("decision");
if (decision !== "approve" && decision !== "read-only") {
const denied = await oauth.denyConsent(request, handle);
return new Response(null, { status: 302, headers: denied.headers });
}
const consented = await oauth.approveConsent(request, handle);
// Least scope: read, and write only when the client asked for it and the person did not narrow
// the grant to reading. The callback grants exactly this.
const approved = { ...consented, request: { ...consented.request, scope: grantScopes(consented.request.scope, { readOnly: decision === "read-only" }) } };
// Only now, after consent, does the sign-in start. The verifier and nonce stay server-side.
const signIn = newSignIn();
const { state, headers } = await oauth.beginUpstream(approved.request, { data: signIn, headers: approved.headers });
return redirect(await authorizationUrl(saas, { redirectUri: `${mcpOrigin(env)}/callback`, state, ...signIn }), headers);
}
async function callback(request: Request, env: DoorEnv): Promise<Response> {
const saas = accessSaas(env);
if (!saas) return text("Carrel's AI door is not configured. Not configured means not open.", 503);
const oauth = env.OAUTH_PROVIDER;
const { request: original, data, headers } = await oauth.finishUpstream<{ verifier: string; nonce: string }>(request);
const params = new URL(request.url).searchParams;
const code = params.get("code");
if (params.get("error") || !code) return redirectError(original, "Sign-in with Cloudflare Access did not complete.", headers);
const signedIn = await finishSignIn(saas, { code, redirectUri: `${mcpOrigin(env)}/callback`, verifier: data.verifier, nonce: data.nonce });
if (!signedIn.ok) {
console.warn(JSON.stringify({ door: "sign-in-refused", reason: signedIn.reason }));
return redirectError(original, "Carrel could not verify the sign-in.", headers);
}
const viewer = await findViewer(env.DB, signedIn.email);
if (!viewer) {
// Access let them in, but Carrel does not know them: no row, no access (design section 3).
console.warn(JSON.stringify({ door: "refused", reason: "unknown-person" }));
return redirectError(original, "This person is not in Carrel.", headers);
}
const client = clientLabel(await oauth.lookupClient(original.clientId).catch(() => null), original.clientId);
const props: DoorProps = { personId: viewer.id, email: viewer.email, client, clientId: original.clientId };
const { redirectTo } = await oauth.completeAuthorization({
request: original,
userId: String(viewer.id),
metadata: { client },
scope: original.scope,
props,
});
headers.set("Location", redirectTo);
headers.set("Cache-Control", "no-store");
return new Response(null, { status: 302, headers });
}
/** Everything on this host the provider does not own. */
const loginHandler = {
async fetch(request: Request, env: DoorEnv): Promise<Response> {
const { pathname } = new URL(request.url);
try {
if (pathname === "/health") return liveness(env);
if (pathname === "/authorize" && request.method === "GET") return await authorizeGet(request, env);
if (pathname === "/authorize" && request.method === "POST") return await authorizePost(request, env);
if (pathname === "/callback" && request.method === "GET") return await callback(request, env);
return text("Not found.", 404);
} catch (error) {
// Per the provider's consent-page guide: redirect only to a validated client, render the rest.
if (error instanceof AuthorizationError && error.redirectUri) {
const url = new URL(error.redirectUri);
url.searchParams.set("error", error.code);
url.searchParams.set("error_description", error.description);
if (error.state) url.searchParams.set("state", error.state);
if (error.issuer) url.searchParams.set("iss", error.issuer);
return redirect(url.href);
}
if (error instanceof AuthorizationError) return text(`${error.description} Start the sign-in again from your MCP client.`, 400);
if (error instanceof CimdFetchError) return text("This app could not be verified: its client metadata document could not be fetched.", 400);
throw error;
}
},
};
/**
* The authenticated MCP surface. The provider has checked the token by the time this runs. The
* person is looked up again on every request: a grant lives for weeks, and a person disabled in
* Carrel must lose the door at once, not when the grant expires.
*/
export const apiHandler = {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const { props, auth } = ctx as ExecutionContext & { props?: Partial<DoorProps>; auth?: { audience?: string; scope?: string[] } };
// The token must have been issued for this server (RFC 8707), checked here as well as by the
// provider, so a provider set up for a second resource could never hand Carrel a token for it.
// Nothing passes the token on: the site and the novels repository take Carrel's own keys.
if (auth?.audience !== `${mcpOrigin(env)}${MCP_ROUTE}`) {
console.warn(JSON.stringify({ door: "refused", reason: "audience", audience: auth?.audience ?? null }));
return text("Forbidden. This token was not issued for Carrel.", 403);
}
const viewer = props?.email ? await findViewer(env.DB, props.email) : null;
if (!viewer || viewer.id !== props?.personId) {
console.warn(JSON.stringify({ door: "refused", reason: viewer ? "person-changed" : "unknown-person" }));
return text("Forbidden. This grant does not belong to an active person in Carrel.", 403);
}
const session = { viewer, client: props.client || "an unnamed AI client", scopes: effectiveScopes(auth.scope ?? []) };
const response = await handleMcp(request, env, ctx, session, { carrelOrigin: carrelOrigin(env) });
const out = new Response(response.body, response);
for (const [name, value] of Object.entries(PRIVATE_HEADERS)) out.headers.set(name, value);
return out;
},
};
/**
* The named-agent surface. A key (agent-keys.server.ts) names an agent, and the agent is the person
* row "agent:<name>" in Carrel's People table, found again on every request: disabling that row
* revokes the agent at once, and its role on each project is whatever People says, never the key's.
* A key with no active row is refused here and never falls through to anything. Writes are credited
* to "agent:<name>", the same text as the person, never to Dustin.
*/
async function agentHandler(request: Request, env: Env, ctx: ExecutionContext, name: string): Promise<Response> {
const viewer = await findAgentViewer(env.DB, name);
if (!viewer) {
console.warn(JSON.stringify({ door: "refused", reason: "agent-not-in-carrel", agent: name }));
return text("Forbidden. This agent key does not belong to an active agent in Carrel.", 403);
}
console.log(JSON.stringify({ door: "agent-key-accepted", agent: name }));
const response = await handleMcp(request, env, ctx, { viewer, client: agentClient(name), scopes: AGENT_SCOPES }, { carrelOrigin: carrelOrigin(env) });
const out = new Response(response.body, response);
for (const [header, value] of Object.entries(PRIVATE_HEADERS)) out.headers.set(header, value);
return out;
}
const providers = new Map<string, OAuthProvider<Env>>();
/**
* One provider per origin and registration allowlist: the resource URI and the registration policy
* are both fixed at construction, so a changed allowlist gets a new provider, never a stale one.
*/
function providerFor(env: Env): OAuthProvider<Env> {
const origin = mcpOrigin(env);
const allowed = allowedRedirects(env);
const key = `${origin} ${[...allowed].join(" ")}`;
let provider = providers.get(key);
if (!provider) {
provider = new OAuthProvider<Env>({
apiRoute: MCP_ROUTE,
apiHandler,
defaultHandler: loginHandler as unknown as ExportedHandler<Env>,
authorizeEndpoint: "/authorize",
tokenEndpoint: "/token",
scopesSupported: SCOPES_SUPPORTED,
resourceMetadata: {
resource: `${origin}${MCP_ROUTE}`,
authorization_servers: [origin],
scopes_supported: SCOPES_SUPPORTED,
resource_name: "Carrel",
},
// CIMD first. claude.ai, Claude Code and ChatGPT send their client_id as a metadata URL and
// never register (measured in dustinedwards-mcp, 2026-07-30). Needs global_fetch_strictly_public
// in wrangler.jsonc.
clientIdMetadataDocumentEnabled: true,
// Tightened registration beside it, for Grok and Gemini (rule 13, registration.ts): exact
// callbacks only, a code and refresh grant only, rate limited in aiDoor before it gets here.
clientRegistrationEndpoint: REGISTER_PATH,
clientRegistrationCallback: ({ clientMetadata }) => checkRegistration(allowed, clientMetadata),
});
providers.set(key, provider);
}
return provider;
}
/** Serves one request that arrived on the AI door's hostname. */
export async function aiDoor(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const { pathname } = new URL(request.url);
if (pathname === MCP_ROUTE || pathname.startsWith(`${MCP_ROUTE}/`)) {
// Tried, never required: no match (no secret, a bad header, an OAuth token) is the OAuth path, unchanged.
const agent = await agentPrincipal(env, request.headers.get("Authorization"));
if (agent) return agentHandler(request, env, ctx, agent);
}
if (pathname === REGISTER_PATH && request.method === "POST") {
const limited = await registrationLimited(request, env);
if (limited) return limited;
}
return providerFor(env).fetch(request, env, ctx);
}