Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/host-selfhost/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => {
routes: [
// Better Auth owns /api/auth/* — the full path reaches it unmodified.
HttpRouter.add("*", "/api/auth/*", HttpEffect.fromWebHandler(authHandler)),
// Browser approval of paused MCP executions: the console resume page
// reads paused detail (GET) and records the decision (POST .../resume),
// session-cookie-gated, delegating to the in-process MCP store.
HttpRouter.add("*", "/api/mcp-sessions/*", HttpEffect.fromWebHandler(mcp.approvalHandler)),
// App-local admin (invite-code) API, served under /api/admin/*.
makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),
// Public system API: /api/health + /api/setup-status (unauthenticated).
Expand Down
42 changes: 41 additions & 1 deletion apps/host-selfhost/src/mcp/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Layer } from "effect";
import { Effect, Layer } from "effect";

import { IdentityProvider } from "@executor-js/api/server";
import type { McpAuthProvider, McpErrorReporter, McpSessionStore } from "@executor-js/host-mcp";
Expand Down Expand Up @@ -50,10 +50,49 @@ export interface SelfHostMcpSeams {
readonly sessions: Layer.Layer<McpSessionStore>;
/** Route 500 defects through the host's console `ErrorCapture`. */
readonly reporter: Layer.Layer<McpErrorReporter>;
/**
* 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<Response>;
/** Dispose all live in-process MCP sessions at shutdown (not a seam). */
readonly close: () => Promise<void>;
}

const jsonResponse = (value: unknown, status: number): Response =>
new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } });

/**
* 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. Single-tenant: any authenticated
* user of the one org may act on a session it still holds — the store confirms
* the execution belongs to the addressed session before recording.
*/
const makeApprovalHandler =
(
store: ReturnType<typeof makeSelfHostMcpSessionStore>,
betterAuth: BetterAuthHandle,
): ((request: Request) => Promise<Response>) =>
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);

return (
(await store.handlePausedRequest(request)) ??
(await store.handleApprovalRequest(request)) ??
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
Expand All @@ -73,6 +112,7 @@ export const makeSelfHostMcpSeams = (
auth,
sessions: selfHostMcpSessions(sessionStore),
reporter: selfHostMcpReporter,
approvalHandler: makeApprovalHandler(sessionStore, betterAuth),
close: sessionStore.close,
};
};
98 changes: 39 additions & 59 deletions apps/local/src/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Deferred, Effect } from "effect";
import { Effect } from "effect";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
Expand All @@ -14,7 +14,8 @@ import {
formatResumeAcknowledgement,
readElicitationMode,
} from "@executor-js/host-mcp/browser-approval";
import type { ResumeResponse } from "@executor-js/execution";
import { makeInProcessBrowserApprovalStore } from "@executor-js/host-mcp/browser-approval-store";
import { formatPausedExecution, type ResumeResponse } from "@executor-js/execution";

import { startIntegrationsRefresh } from "./integrations";

Expand All @@ -24,6 +25,9 @@ import { startIntegrationsRefresh } from "./integrations";

export type McpRequestHandler = {
readonly handleRequest: (request: Request) => Promise<Response>;
/** GET `/api/mcp-sessions/:id/executions/:id` — paused detail for the console. */
readonly handlePausedRequest: (request: Request) => Promise<Response>;
/** POST `/api/mcp-sessions/:id/executions/:id/resume` — record the decision. */
readonly handleApprovalRequest: (request: Request) => Promise<Response>;
readonly close: () => Promise<void>;
};
Expand Down Expand Up @@ -52,6 +56,7 @@ const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> =>
)
: Promise.resolve();

const pausedRequestPattern = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/;
const approvalRequestPattern = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/;

const json = (value: unknown, status = 200): Response =>
Expand All @@ -77,16 +82,29 @@ const resumeApprovalResult = (executionId: string, response: ResumeResponse) =>
export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpRequestHandler => {
const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
const servers = new Map<string, McpServer>();
const approvalResponses = new Map<string, Map<string, ResumeResponse>>();
const approvalWaiters = new Map<string, Map<string, Deferred.Deferred<ResumeResponse>>>();
const approvals = makeInProcessBrowserApprovalStore();
// Local runs one shared engine across every MCP session (main.ts builds it and
// passes it in), so the paused-execution lookup for browser approval reads it
// directly — there is no per-session engine to track.
const engine = "engine" in config ? config.engine : null;

const pausedDetail = (
executionId: string,
): Promise<ReturnType<typeof formatPausedExecution> | null> =>
engine
? Effect.runPromise(
engine.getPausedExecution(executionId).pipe(
Effect.map((paused) => (paused ? formatPausedExecution(paused) : null)),
Effect.orElseSucceed(() => null),
),
)
: Promise.resolve(null);

const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => {
const t = transports.get(id);
const s = servers.get(id);
transports.delete(id);
servers.delete(id);
approvalResponses.delete(id);
approvalWaiters.delete(id);
if (opts.transport) await ignoreClose(t ? () => t.close() : undefined);
if (opts.server) await ignoreClose(s ? () => s.close() : undefined);
};
Expand Down Expand Up @@ -125,48 +143,7 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq
created = await Effect.runPromise(
createExecutorMcpServer({
...config,
browserApprovalStore: {
takeResponse: (executionId) =>
Effect.sync(() => {
if (!createdSessionId) return null;
const sessionApprovals = approvalResponses.get(createdSessionId);
const response = sessionApprovals?.get(executionId) ?? null;
sessionApprovals?.delete(executionId);
return response;
}),
waitForResponse: (executionId) =>
Effect.gen(function* () {
if (!createdSessionId) return null;
const sessionApprovals = approvalResponses.get(createdSessionId);
const response = sessionApprovals?.get(executionId) ?? null;
if (response) {
sessionApprovals?.delete(executionId);
return response;
}

const sessionWaiters =
approvalWaiters.get(createdSessionId) ??
new Map<string, Deferred.Deferred<ResumeResponse>>();
const waiter =
sessionWaiters.get(executionId) ?? (yield* Deferred.make<ResumeResponse>());
sessionWaiters.set(executionId, waiter);
approvalWaiters.set(createdSessionId, sessionWaiters);

yield* Deferred.await(waiter).pipe(
Effect.ensuring(
Effect.sync(() => {
if (sessionWaiters.get(executionId) === waiter) {
sessionWaiters.delete(executionId);
}
}),
),
);
const approvals = approvalResponses.get(createdSessionId);
const approved = approvals?.get(executionId) ?? null;
approvals?.delete(executionId);
return approved;
}),
},
browserApprovalStore: approvals.store,
elicitationMode:
elicitationMode === "browser"
? {
Expand Down Expand Up @@ -197,26 +174,29 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq
}
},

handlePausedRequest: async (request) => {
const match = pausedRequestPattern.exec(new URL(request.url).pathname);
if (!match) return json({ error: "Not found" }, 404);
if (request.method !== "GET") return json({ error: "Method not allowed" }, 405);

const paused = await pausedDetail(decodeURIComponent(match[2]));
if (!paused) return json({ error: "Paused execution not found" }, 404);
return json({ text: paused.text, structured: paused.structured });
},

handleApprovalRequest: async (request) => {
const url = new URL(request.url);
const match = approvalRequestPattern.exec(url.pathname);
const match = approvalRequestPattern.exec(new URL(request.url).pathname);
if (!match) return json({ error: "Not found" }, 404);
if (request.method !== "POST") return json({ error: "Method not allowed" }, 405);

const sessionId = decodeURIComponent(match[1]);
const executionId = decodeURIComponent(match[2]);
if (!servers.has(sessionId)) return json({ error: "MCP session not found" }, 404);
// The shared engine must still hold the paused execution — guards stale ids.
if (!(await pausedDetail(executionId))) return json({ error: "MCP session not found" }, 404);

const response = await readResumeResponse(request);
if (!response) return json({ error: "Invalid approval response" }, 400);

const sessionApprovals =
approvalResponses.get(sessionId) ?? new Map<string, ResumeResponse>();
sessionApprovals.set(executionId, response);
approvalResponses.set(sessionId, sessionApprovals);
const waiter = approvalWaiters.get(sessionId)?.get(executionId);
if (waiter) await Effect.runPromise(Deferred.succeed(waiter, response));

await Effect.runPromise(approvals.recordResponse(executionId, response));
return json(resumeApprovalResult(executionId, response));
},

Expand Down
5 changes: 5 additions & 0 deletions apps/local/src/serve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const startTestServer = async (): Promise<string> => {
mcp: {
handleRequest: async () => new Response("ok"),
handleApprovalRequest: async () => new Response("ok"),
handlePausedRequest: async () => new Response("ok"),
close: async () => {},
},
},
Expand Down Expand Up @@ -80,6 +81,7 @@ describe("startServer network bind auth", () => {
mcp: {
handleRequest: async () => new Response("ok"),
handleApprovalRequest: async () => new Response("ok"),
handlePausedRequest: async () => new Response("ok"),
close: async () => {},
},
},
Expand All @@ -101,6 +103,7 @@ describe("startServer network bind auth", () => {
mcp: {
handleRequest: async () => new Response("ok"),
handleApprovalRequest: async () => new Response("ok"),
handlePausedRequest: async () => new Response("ok"),
close: async () => {},
},
},
Expand Down Expand Up @@ -131,6 +134,7 @@ describe("startServer network bind auth", () => {
mcp: {
handleRequest: async () => new Response("ok"),
handleApprovalRequest: async () => new Response("ok"),
handlePausedRequest: async () => new Response("ok"),
close: async () => {},
},
},
Expand Down Expand Up @@ -166,6 +170,7 @@ describe("startServer network bind auth", () => {
mcp: {
handleRequest: async () => new Response("ok"),
handleApprovalRequest: async () => new Response("ok"),
handlePausedRequest: async () => new Response("ok"),
close: async () => {},
},
},
Expand Down
6 changes: 5 additions & 1 deletion apps/local/src/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,11 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server
}

if (url.pathname.startsWith("/api/mcp-sessions/")) {
return maybeWithCorsHeaders(await handlers.mcp.handleApprovalRequest(req));
const handler =
req.method === "GET"
? handlers.mcp.handlePausedRequest
: handlers.mcp.handleApprovalRequest;
return maybeWithCorsHeaders(await handler(req));
}

// OAuth result polling — local-only, served outside the typed API
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@
// The policy is removed in an `ensuring` finalizer — a leaked require_approval
// gate on a shared built-in tool would pause unrelated scenarios.
//
// Lives under cloud/ for now because cloud is the only host wired for browser
// approval; it moves to scenarios/ (cross-target) as self-host and Cloudflare
// gain the feature.
// Cross-target: runs on every host that wires browser approval (cloud's Durable
// Object, self-host's in-process store, Cloudflare's DO). The host differences —
// where the approval URL points, which engine holds the pause — are invisible
// here; the scenario only drives the rendered console page.
import { expect } from "@effect/vitest";
import { Effect } from "effect";
import { composePluginApi } from "@executor-js/api/server";
Expand Down
9 changes: 7 additions & 2 deletions packages/core/api/src/server/mcp-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { McpErrorReporter, type Principal } from "@executor-js/host-mcp";
import {
McpEngineBuildError,
type McpBuildServer,
type McpBuildServerOptions,
} from "@executor-js/host-mcp/in-memory-session-store";
import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";

Expand Down Expand Up @@ -37,7 +38,7 @@ export type McpExecutionStackLayer = Layer.Layer<
*/
export const makeMcpBuildServer =
(executionStack: McpExecutionStackLayer): McpBuildServer =>
(principal: Principal) =>
(principal: Principal, options?: McpBuildServerOptions) =>
makeExecutionStack(
principal.accountId,
principal.organizationId,
Expand All @@ -46,7 +47,11 @@ export const makeMcpBuildServer =
Effect.map(({ engine }) => engine),
Effect.provide(executionStack),
Effect.mapError((cause) => new McpEngineBuildError({ cause })),
Effect.flatMap((engine) => createExecutorMcpServer({ engine })),
Effect.flatMap((engine) =>
createExecutorMcpServer({ engine, ...(options ?? {}) }).pipe(
Effect.map((mcpServer) => ({ mcpServer, engine })),
),
),
);

/**
Expand Down
4 changes: 4 additions & 0 deletions packages/hosts/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
"./browser-approval": {
"types": "./src/browser-approval.ts",
"default": "./src/browser-approval.ts"
},
"./browser-approval-store": {
"types": "./src/browser-approval-store.ts",
"default": "./src/browser-approval-store.ts"
}
},
"scripts": {
Expand Down
Loading
Loading