Skip to content

self-host: browser approval of gated MCP actions - #1015

Merged
RhysSullivan merged 1 commit into
mainfrom
claude/mcp-browser-approval-selfhost
Jun 14, 2026
Merged

self-host: browser approval of gated MCP actions#1015
RhysSullivan merged 1 commit into
mainfrom
claude/mcp-browser-approval-selfhost

Conversation

@RhysSullivan

@RhysSullivan RhysSullivan commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

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. makeMcpBuildServer returns { mcpServer, engine }.
  • host-self-host: mounts the approval endpoints at /api/mcp-sessions/*, gated by a session.
  • React: the shared resume page fetches paused detail session-scoped, so the in-process hosts resolve it from the right engine.
  • Local app: 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.

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 / lint clean.
  • Full suites for the changed packages green (host-mcp, host-self-host, local, react, api).
  • Browser-approval e2e (approve + decline) now green on self-host too, driving the rendered resume page.

Second of a three-PR stack.

Stack

  1. host-mcp: shared browser-approval primitives + cloud browser e2e #1014
  2. self-host: browser approval of gated MCP actions #1015 👈 current
  3. cloudflare self-host: browser approval + a Cloudflare e2e target #1016

@RhysSullivan
RhysSullivan force-pushed the claude/mcp-browser-approval-shared branch from 1af9520 to e2b6bc5 Compare June 14, 2026 03:12
@RhysSullivan
RhysSullivan force-pushed the claude/mcp-browser-approval-selfhost branch from a595fe9 to 69e930c Compare June 14, 2026 03:12
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 14, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 14, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
executor-cloud e3c90bb Jun 14 2026, 06:02 AM

@github-actions

github-actions Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Cloudflare preview

Torn down — the PR is closed.

@pkg-pr-new

pkg-pr-new Bot commented Jun 14, 2026

Copy link
Copy Markdown

Open in StackBlitz

@executor-js/cli

npm i https://pkg.pr.new/@executor-js/cli@1015

@executor-js/config

npm i https://pkg.pr.new/@executor-js/config@1015

@executor-js/execution

npm i https://pkg.pr.new/@executor-js/execution@1015

@executor-js/sdk

npm i https://pkg.pr.new/@executor-js/sdk@1015

@executor-js/codemode-core

npm i https://pkg.pr.new/@executor-js/codemode-core@1015

@executor-js/runtime-quickjs

npm i https://pkg.pr.new/@executor-js/runtime-quickjs@1015

@executor-js/plugin-file-secrets

npm i https://pkg.pr.new/@executor-js/plugin-file-secrets@1015

@executor-js/plugin-graphql

npm i https://pkg.pr.new/@executor-js/plugin-graphql@1015

@executor-js/plugin-keychain

npm i https://pkg.pr.new/@executor-js/plugin-keychain@1015

@executor-js/plugin-mcp

npm i https://pkg.pr.new/@executor-js/plugin-mcp@1015

@executor-js/plugin-onepassword

npm i https://pkg.pr.new/@executor-js/plugin-onepassword@1015

@executor-js/plugin-openapi

npm i https://pkg.pr.new/@executor-js/plugin-openapi@1015

executor

npm i https://pkg.pr.new/executor@1015

commit: e3c90bb

@greptile-apps

greptile-apps Bot commented Jun 14, 2026

Copy link
Copy Markdown

Greptile Summary

This 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 /api/executions/:id route, so the shared resume page could not render the approval UI for these hosts.

  • packages/hosts/mcp/src/browser-approval-store.ts — new shared InProcessBrowserApprovalStore extracted from the local app's duplicated waiter/response maps; now used by both makeInMemoryMcpSessionStore (self-host) and createMcpRequestHandler (local).
  • packages/hosts/mcp/src/in-memory-session-store.ts — builds sessions in browser mode when ?elicitation_mode=browser is present, tracks per-session engines, and exposes handlePausedRequest (GET) and handleApprovalRequest (POST) that the approval handler delegates to.
  • apps/host-selfhost / apps/local / packages/react — mount the session-scoped approval endpoints and update the resume page to fetch paused detail from the correct engine rather than the session-less execution API.

Confidence Score: 5/5

Safe 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

Filename Overview
packages/hosts/mcp/src/browser-approval-store.ts New shared in-process approval store extracted from local app; Deferred-based waiter/response pattern is correct for single-threaded JS execution.
packages/hosts/mcp/src/in-memory-session-store.ts Largest change — adds per-session engine tracking, browser-mode build options, and HTTP paused/resume handlers; session-ownership check via pausedFromSession correctly gates approval to the owning session.
apps/host-selfhost/src/mcp/index.ts Adds session-cookie-gated approvalHandler that delegates to the in-process store; auth error handling (tryPromise + orElseSucceed) is correct.
apps/local/src/mcp.ts Drops duplicated waiter/response maps in favour of the shared approval store; new handlePausedRequest correctly falls back to null when no engine is configured.
apps/local/src/serve.ts Dispatches /api/mcp-sessions/* by method (GET→handlePausedRequest, else→handleApprovalRequest); HEAD requests to the paused-detail path reach handleApprovalRequest instead of handlePausedRequest.
packages/react/src/routes/resume.$executionId.tsx Switches from session-less pausedExecutionAtom to a session-scoped fetch via mcpPausedExecutionAtom; same-origin fetch correctly carries cookies without credentials:include.
packages/core/api/src/server/mcp-build.ts Return type updated from McpServer to BuiltMcpServer ({mcpServer, engine}); consistent with existing cloud/Cloudflare DO implementations.
e2e/scenarios/browser-approval.test.ts Moved from cloud/ to scenarios/ (cross-target) to cover self-host now that browser approval is wired; comment updated to reflect new scope.

Sequence Diagram

sequenceDiagram
    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
Loading

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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!

@RhysSullivan
RhysSullivan force-pushed the claude/mcp-browser-approval-selfhost branch from 69e930c to 14eb2c5 Compare June 14, 2026 05:47
@RhysSullivan
RhysSullivan force-pushed the claude/mcp-browser-approval-shared branch from f110b67 to 88ae2c1 Compare June 14, 2026 05:49
@RhysSullivan
RhysSullivan force-pushed the claude/mcp-browser-approval-selfhost branch from 14eb2c5 to 11c1a17 Compare June 14, 2026 05:51
@RhysSullivan
RhysSullivan changed the base branch from claude/mcp-browser-approval-shared to main June 14, 2026 05:59
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.
@RhysSullivan
RhysSullivan force-pushed the claude/mcp-browser-approval-selfhost branch from 11c1a17 to e3c90bb Compare June 14, 2026 06:00
@RhysSullivan
RhysSullivan merged commit 534b32c into main Jun 14, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant