self-host: browser approval of gated MCP actions - #1015
Conversation
1af9520 to
e2b6bc5
Compare
a595fe9 to
69e930c
Compare
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
executor-marketing | e3c90bb | Commit Preview URL Branch Preview URL |
Jun 14 2026, 06:02 AM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
executor-cloud | e3c90bb | Jun 14 2026, 06:02 AM |
Cloudflare previewTorn down — the PR is closed. |
@executor-js/cli
@executor-js/config
@executor-js/execution
@executor-js/sdk
@executor-js/codemode-core
@executor-js/runtime-quickjs
@executor-js/plugin-file-secrets
@executor-js/plugin-graphql
@executor-js/plugin-keychain
@executor-js/plugin-mcp
@executor-js/plugin-onepassword
@executor-js/plugin-openapi
executor
commit: |
Greptile SummaryThis PR adds browser-based human approval of gated MCP actions for the self-host and local-app deployments. Previously, paused MCP executions lived in an in-memory, per-engine map that was unreachable from the session-less
Confidence Score: 5/5Safe to merge — the browser-approval flow is correctly wired end-to-end and the shared approval store is a clean extraction of the pattern already proven in the local app. All changed paths are either straightforward delegation or direct ports of the existing local-app waiter/response logic into a shared module. The new HTTP handlers correctly scope validation to the owning session's engine. The React resume page uses a relative URL so cookies are carried without explicit credentials configuration. No auth bypass, data-loss path, or logic inversion was found. No files require special attention; the method-dispatch quirk in apps/local/src/serve.ts (HEAD routing) is a pre-existing concern already under discussion. Important Files Changed
Sequence DiagramsequenceDiagram
participant C as MCP Client
participant S as InMemoryMcpSessionStore
participant E as ExecutionEngine
participant A as InProcessBrowserApprovalStore
participant B as Browser (React resume page)
participant H as approvalHandler (session-cookie-gated)
C->>S: "POST /mcp?elicitation_mode=browser (initialize)"
S->>E: "buildServer(principal, { mode: browser, approvalUrl, store: A })"
E-->>S: "{ mcpServer, engine }"
S->>S: engines.set(sessionId, engine)
S-->>C: 200 (session-id header)
C->>S: POST /mcp (call execute tool)
S->>E: run execution
E->>A: waitForResponse(executionId) [suspends]
S-->>C: pause notification + approvalUrl
B->>H: GET /api/mcp-sessions/:sid/executions/:eid
H->>H: betterAuth.getSession() — 401 if unauthenticated
H->>S: handlePausedRequest(request)
S->>E: getPausedExecution(executionId)
E-->>S: pausedExecution
S-->>H: "{ text, structured }"
H-->>B: "200 { text, structured }"
B->>H: POST /api/mcp-sessions/:sid/executions/:eid/resume
H->>S: handleApprovalRequest(request)
S->>A: recordResponse(executionId, response)
A->>A: responses.set() + Deferred.succeed(waiter)
A-->>E: waitForResponse resumes
E->>E: continue execution
S-->>H: "200 { status: completed }"
H-->>B: 200
Reviews (2): Last reviewed commit: "self-host: browser approval of gated MCP..." | Re-trigger Greptile |
| /** Record a human's decision, waking any in-flight `waitForResponse`. */ | ||
| readonly recordResponse: (executionId: string, response: ResumeResponse) => Effect.Effect<void>; | ||
| /** Drop a pending decision/waiter (e.g. when its session is torn down). */ | ||
| readonly forget: (executionId: string) => void; |
There was a problem hiding this comment.
forget is documented as "Drop a pending decision/waiter (e.g. when its session is torn down)" but is never called anywhere in the new code. Both makeInMemoryMcpSessionStore's dispose and the local createMcpRequestHandler's dispose remove the engine/transport/server entries but never invoke approvals.forget(executionId). Because the session store has no executionId → sessionId mapping, there's no practical place to call it today.
Effect fiber interruption (the ensuring clause in waitFor) handles the waiters map on fiber teardown, but any entry that lands in responses after a session closes — e.g. a POST /resume that wins a race with session teardown — stays there permanently. For a long-lived self-host process this is a bounded but unbounded-growth memory leak. Consider either wiring a cleanup path (e.g. tracking executionIds per session in the store) or removing forget from the public interface and adding a comment explaining why it isn't needed.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
69e930c to
14eb2c5
Compare
f110b67 to
88ae2c1
Compare
14eb2c5 to
11c1a17
Compare
Self-host (and the local app) could pause a gated MCP execution but had
no way for a human to approve it in the browser: the in-process MCP store
served model-mode only, and the shared resume page fetched paused detail
from the session-less /api/executions/:id — a different engine that never
holds the MCP pause.
- @executor-js/host-mcp: an in-process browser-approval store (the resume
long-poll <-> HTTP decision bridge, keyed by executionId), and the
in-memory session store now builds browser-mode servers, keeps each
session's engine, and serves the session-scoped paused (GET) + resume
(POST) endpoints. makeMcpBuildServer returns { mcpServer, engine }.
- host-selfhost: mounts the approval endpoints at /api/mcp-sessions/*,
gated by a Better Auth session.
- react: the shared resume page fetches paused detail session-scoped, so
the in-process hosts resolve it from the right engine (mirrors cloud).
- apps/local: migrated onto the shared approval store + the new GET
handler, dropping its duplicated waiter maps; its browser approval page
now works, not just the API-direct path.
- e2e: browser-approval scenarios moved to scenarios/ (cross-target) —
approve + decline now green on self-host too.
11c1a17 to
e3c90bb
Compare
What
Self-host (and the local app) could pause a gated MCP execution but had no way for a human to approve it in the browser: the in-process MCP store served model-mode only, and the shared resume page fetched paused detail from the session-less
/api/executions/:id— a different engine that never holds the MCP pause.@executor-js/host-mcp: an in-process browser-approval store (the resume long-poll ↔ HTTP-decision bridge, keyed by execution id), and the in-memory session store now builds browser-mode servers, keeps each session's engine, and serves the session-scoped paused (GET) + resume (POST) endpoints.makeMcpBuildServerreturns{ mcpServer, engine }./api/mcp-sessions/*, gated by a session.Why the change is bigger than a flag
Paused executions live in an in-memory, per-engine map (not the DB), so a paused MCP execution is only visible to the engine that created it. That is why the session-scoped endpoints + the shared page change are needed on the in-process hosts.
Testing
typecheck/format:check/lintclean.Second of a three-PR stack.
Stack