Skip to content

host-mcp: shared browser-approval primitives + cloud browser e2e - #1014

Merged
RhysSullivan merged 3 commits into
mainfrom
claude/mcp-browser-approval-shared
Jun 14, 2026
Merged

host-mcp: shared browser-approval primitives + cloud browser e2e#1014
RhysSullivan merged 3 commits into
mainfrom
claude/mcp-browser-approval-shared

Conversation

@RhysSullivan

@RhysSullivan RhysSullivan commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

What

The browser-approval wire shape (elicitation-mode query parsing, the /resume approval URL, the resume-payload schema, the decision acknowledgement) was copy-pasted across the in-process MCP handler and the Durable Object base. This hoists it into @executor-js/host-mcp/browser-approval and refactors the existing copies onto it — behaviour-preserving; each host keeps only its transport envelope.

It also adds the first end-to-end coverage of a human approving/declining a gated MCP action in the rendered console /resume page — the leg unit tests structurally cannot reach. The e2e MCP surface gains a browser elicitation mode and a paused-approval parser.

Notes

  • Fixes a latent e2e-surface bug: the MCP client caches OAuth tokens by server name, so the prior constant name let a later session reuse an earlier identity's token (wrong org). Now each session gets a unique name. The decline scenario was the first identity-sensitive flow to expose it.

Testing

  • typecheck / format:check / lint clean.
  • host-mcp + the local browser-resume integration test green.
  • Cloud browser-approval e2e (approve + decline) green against the real dev server.

First of a three-PR stack (cloud → self-host → Cloudflare self-host).

Stack

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

@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 88ae2c1 Commit Preview URL

Branch Preview URL
Jun 14 2026, 05:50 AM

The browser-approval wire shape (elicitation-mode query parsing, the
/resume approval URL, the resume-payload schema, and the decision
acknowledgement) was copy-pasted across the in-process handler
(apps/local) and the Durable Object base (@executor-js/cloudflare).

Hoist it into @executor-js/host-mcp/browser-approval and refactor the
existing copies onto it. Each host keeps only its transport envelope
(HTTP JSON vs DO RPC result), which is the part that legitimately
differs. Behaviour-preserving; no wire changes.
Adds the first end-to-end coverage of a human approving/declining a
gated MCP action in the rendered console approval page — the leg unit
tests structurally cannot reach.

- MCP surface: a session can run in elicitation_mode=browser, exposes
  awaitResume (the no-action long-poll resume), and parseBrowserApproval
  pulls the {executionId, approvalUrl} out of a paused result.
- Surface fix: give each MCP session a unique mcporter server name.
  mcporter caches OAuth tokens per server name, so the old constant name
  let a later session reuse an earlier identity's token (wrong org). A
  decline scenario was the first identity-sensitive flow to expose it.
- cloud/browser-approval.test.ts: approve runs the gated tool to
  completion; decline blocks it. Moves to scenarios/ once self-host and
  Cloudflare gain the feature.
@RhysSullivan
RhysSullivan force-pushed the claude/mcp-browser-approval-shared branch from 1af9520 to e2b6bc5 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 Updated (UTC)
✅ Deployment successful!
View logs
executor-cloud 88ae2c1 Jun 14 2026, 05:52 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@1014

@executor-js/config

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

@executor-js/execution

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

@executor-js/sdk

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

@executor-js/codemode-core

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

@executor-js/runtime-quickjs

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

@executor-js/plugin-file-secrets

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

@executor-js/plugin-graphql

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

@executor-js/plugin-keychain

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

@executor-js/plugin-mcp

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

@executor-js/plugin-onepassword

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

@executor-js/plugin-openapi

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

executor

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

commit: 88ae2c1

@greptile-apps

greptile-apps Bot commented Jun 14, 2026

Copy link
Copy Markdown

Greptile Summary

This PR hoists duplicated browser-approval wire-shape logic (elicitation-mode query parsing, approval URL construction, resume-payload schema, and acknowledgement text) into a new shared @executor-js/host-mcp/browser-approval module, and refactors the existing copies in the cloud app, local app, and Cloudflare host onto it. It also adds the first end-to-end coverage of a human approving/declining a gated MCP action in the rendered /resume console page.

  • New shared primitives (packages/hosts/mcp/src/browser-approval.ts): readElicitationMode, buildResumeApprovalUrl, approvalUrlForRequest, decodeResumeResponse, and formatResumeAcknowledgement — each host keeps only its transport envelope.
  • Token cache fix in e2e/src/surfaces/mcp.ts: MCP sessions now get a unique serverName (target.name-<8-char UUID>) to prevent mcporter from reusing an earlier identity's cached OAuth token.
  • e2e cloud tests (e2e/cloud/browser-approval.test.ts): approve and decline paths driven by a real Playwright browser session concurrently with a long-polling awaitResume Effect; policy cleanup is guarded with Effect.ensuring.

Confidence Score: 5/5

Safe to merge — the change is a behaviour-preserving extraction of duplicated logic into a shared module, with no new codepaths in the production hosts and thorough e2e coverage of the new test surface.

All three host implementations (cloud DO, local in-process, Cloudflare DO) delegate to the same shared primitives with no logic change; session IDs are always non-empty DO identifiers so the conditional sessionId guard in buildResumeApprovalUrl is equivalent to the old unconditional set. The token-cache fix is straightforward and correct. The new e2e scenarios exercise the full approve-and-decline round-trip against real infrastructure.

No files require special attention.

Important Files Changed

Filename Overview
packages/hosts/mcp/src/browser-approval.ts New shared module: exposes readElicitationMode, buildResumeApprovalUrl, approvalUrlForRequest, decodeResumeResponse, and formatResumeAcknowledgement — consolidates logic previously duplicated across three host implementations.
e2e/cloud/browser-approval.test.ts New e2e test file: approve and decline scenarios driven by concurrent Playwright browser + long-polling awaitResume; hermetic gate via a require_approval policy cleaned up with Effect.ensuring.
e2e/src/surfaces/mcp.ts Adds McpElicitationMode, McpBrowserApproval, parseBrowserApproval, and awaitResume to the MCP surface; fixes token-cache collision by generating a unique serverName per session.
e2e/targets/selfhost.ts Replaces mcporter's cookieConsentStrategy with a custom forcedMcpConsent that handles the self-host's forced prompt=consent screen by driving the /api/auth/oauth2/consent grant endpoint.
packages/hosts/cloudflare/src/mcp/do-headers.ts Removes the now-duplicated readElicitationMode/McpElicitationMode and re-exports them from the new shared module; import path for callers is unchanged.
apps/cloud/src/mcp/session-durable-object.ts Replaces the inline approval URL construction with buildResumeApprovalUrl; behaviour-preserving since the DO's sessionId is always a non-empty string.
apps/local/src/mcp.ts Removes the local copies of readElicitationMode, approvalUrlForRequest, decodeResumeResponsePayload, and resumeApprovalResult, replacing them with imports from the shared browser-approval module.
packages/hosts/cloudflare/src/mcp/session-durable-object.ts Replaces the inline resumeApprovalResult text/status maps with formatResumeAcknowledgement from the shared module; behaviour-preserving.
packages/hosts/mcp/package.json Adds the ./browser-approval export entry so consumers can import from @executor-js/host-mcp/browser-approval.

Sequence Diagram

sequenceDiagram
    participant Client as MCP Client
    participant Host as Host (cloud/local/CF)
    participant Store as BrowserApprovalStore
    participant Browser as Human Browser

    Client->>Host: "POST /mcp?elicitation_mode=browser"
    Host->>Host: readElicitationMode(request) browser
    Host->>Store: pause execution, store approval slot
    Host-->>Client: status user_approval_required + approvalUrl

    Note over Client,Browser: Concurrently

    Client->>Host: tools/call resume executionId long-polls
    Browser->>Host: GET /resume/:executionId?mcp_session_id
    Browser->>Host: POST decision Approve or Decline
    Host->>Store: record decision

    Store-->>Host: decision consumed by long-poll
    Host-->>Client: formatResumeAcknowledgement text and structured
Loading

Reviews (3): Last reviewed commit: "e2e(selfhost): drive the forced MCP OAut..." | Re-trigger Greptile

Comment thread e2e/src/surfaces/mcp.ts
Comment on lines +266 to +268
const sessionUrl = options?.elicitationMode
? `${target.mcpUrl}?elicitation_mode=${options.elicitationMode}`
: target.mcpUrl;

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 The sessionUrl is built by string-concatenating onto target.mcpUrl rather than using URL APIs. If target.mcpUrl ever carries an existing query string (e.g. from a configured override like http://localhost:3000/mcp?trace=1), the result would be …/mcp?trace=1?elicitation_mode=browser — a URL that no parser will interpret as two separate parameters. Using URL + searchParams avoids this silently.

Suggested change
const sessionUrl = options?.elicitationMode
? `${target.mcpUrl}?elicitation_mode=${options.elicitationMode}`
: target.mcpUrl;
const sessionUrl = (() => {
if (!options?.elicitationMode) return target.mcpUrl;
const u = new URL(target.mcpUrl);
u.searchParams.set("elicitation_mode", options.elicitationMode);
return u.toString();
})();

The self-host serving layer forces prompt=consent on every MCP authorize, so the
headless e2e target completes the /mcp-consent approval the way the page does
(sign in, authorize, POST /api/auth/oauth2/consent) instead of the old
direct-code cookieConsentStrategy.
@RhysSullivan
RhysSullivan force-pushed the claude/mcp-browser-approval-shared branch from f110b67 to 88ae2c1 Compare June 14, 2026 05:49
@RhysSullivan
RhysSullivan merged commit 7fb9220 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