import { Effect, Layer } from "effect"; import { IdentityProvider } from "@executor-js/api/server"; import type { McpAuthProvider, McpErrorReporter, McpSessionStore, Principal, } from "@executor-js/host-mcp"; import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; import type { SelfHostDbHandle } from "../db/self-host-db"; import { selfHostMcpAuth } from "./auth"; import { makeSelfHostMcpSessionStore, selfHostMcpReporter, selfHostMcpSessions, } from "./session-store"; export { selfHostMcpAuth } from "./auth"; export { makeSelfHostMcpSessionStore, selfHostMcpReporter, selfHostMcpSessions, McpEngineBuildError, } from "./session-store"; // --------------------------------------------------------------------------- // The self-host MCP serving seams, fed to `ExecutorApp.make`'s `mcp` group. // // `ExecutorApp.make` mounts the shared, provider-neutral MCP serving envelope // from @executor-js/host-mcp (the two root OAuth discovery docs + the multi-user // /mcp endpoint, top-level per the ecosystem convention). The envelope does its // own auth + session handling and is mounted OUTSIDE the API's execution // middleware, like /api/auth. // // Self-host provides the TWO envelope seams plus an error-reporter override: // - McpAuthProvider -> `selfHostMcpAuth` (Better Auth mcp() OAuth). It still // requires `IdentityProvider`, which `make` provides from // the resolved identity seam. // - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns // dispatch (create + forward + ownership) and builds its // engine internally over the shared SelfHostDb. // - McpErrorReporter -> `selfHostMcpReporter`: route 500 defects through the // host's console capture. // // The OAuth endpoints (/api/auth/mcp/{register,authorize,token}) stay on the // Better Auth handler mounted at /api/auth — not in the envelope. // --------------------------------------------------------------------------- export interface SelfHostMcpSeams { /** Resolve a request to an MCP `AuthOutcome` + declare the discovery routes. */ readonly auth: Layer.Layer; /** The in-process session store seam (dispatch + lifetime). */ readonly sessions: Layer.Layer; /** Route 500 defects through the host's console `ErrorCapture`. */ readonly reporter: Layer.Layer; /** * The browser-approval HTTP handler, mounted by the app at * `/api/mcp-sessions/*`: a session-cookie-gated web handler that serves the * paused-execution detail (GET) and records the human's decision (POST * `/resume`) for the console approval page. Browser elicitation mode only. */ readonly approvalHandler: (request: Request) => Promise; /** Dispose all live in-process MCP sessions at shutdown (not a seam). */ readonly close: () => Promise; } const jsonResponse = (value: unknown, status: number): Response => new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }); const parseRoles = (role: string | null | undefined): ReadonlyArray => (role ?? "user") .split(",") .map((r) => r.trim()) .filter((r) => r.length > 0); type BetterAuthSession = NonNullable< Awaited> >; const principalFromSession = ( resolved: BetterAuthSession, betterAuth: BetterAuthHandle, ): Principal => ({ accountId: resolved.user.id, organizationId: resolved.session.activeOrganizationId ?? betterAuth.organizationId, organizationName: betterAuth.organizationName, email: resolved.user.email, name: resolved.user.name ?? null, avatarUrl: resolved.user.image ?? null, roles: parseRoles(resolved.user.role ?? null), }); /** * Gate the browser-approval endpoints behind a valid Better Auth session (the * console page calls them with the user's cookie), then delegate to the * in-process store's paused/resume handlers with the resolved principal so the * store can enforce MCP session ownership before exposing or recording a * browser-approval decision. */ const makeApprovalHandler = ( store: ReturnType, betterAuth: BetterAuthHandle, ): ((request: Request) => Promise) => async (request) => { // A malformed cookie must read as unauthenticated, not 500. const session = await Effect.runPromise( Effect.tryPromise({ try: () => betterAuth.auth.api.getSession({ headers: request.headers }), catch: () => "session lookup failed", }).pipe(Effect.orElseSucceed(() => null)), ); if (!session) return jsonResponse({ error: "Unauthorized" }, 401); const principal = principalFromSession(session, betterAuth); return ( (await store.handlePausedRequest(request, principal)) ?? (await store.handleApprovalRequest(request, principal)) ?? jsonResponse({ error: "Not found" }, 404) ); }; /** * Build the self-host MCP serving seams over the long-lived DB handle. The auth * seam is `selfHostMcpAuth` (Better Auth mcp() OAuth), with the Better Auth * instance provided; it still requires `IdentityProvider` from the resolved * identity seam. Returns the three seam Layers plus the `close()` lifetime hook * the app wires into shutdown. */ export const makeSelfHostMcpSeams = ( dbHandle: SelfHostDbHandle, betterAuth: BetterAuthHandle, webBaseUrl?: string, ): SelfHostMcpSeams => { const sessionStore = makeSelfHostMcpSessionStore(dbHandle, webBaseUrl); const auth: Layer.Layer = selfHostMcpAuth.pipe( Layer.provide(Layer.succeed(BetterAuth)(betterAuth)), ); return { auth, sessions: selfHostMcpSessions(sessionStore), reporter: selfHostMcpReporter, approvalHandler: makeApprovalHandler(sessionStore, betterAuth), close: sessionStore.close, }; };