host-mcp: shared browser-approval primitives + cloud browser e2e - #1014
Conversation
Deploying with
|
| 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.
1af9520 to
e2b6bc5
Compare
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
executor-cloud | 88ae2c1 | Jun 14 2026, 05:52 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 hoists duplicated browser-approval wire-shape logic (elicitation-mode query parsing, approval URL construction, resume-payload schema, and acknowledgement text) into a new shared
Confidence Score: 5/5Safe 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
Sequence DiagramsequenceDiagram
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
Reviews (3): Last reviewed commit: "e2e(selfhost): drive the forced MCP OAut..." | Re-trigger Greptile |
| const sessionUrl = options?.elicitationMode | ||
| ? `${target.mcpUrl}?elicitation_mode=${options.elicitationMode}` | ||
| : target.mcpUrl; |
There was a problem hiding this comment.
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.
| 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.
f110b67 to
88ae2c1
Compare
What
The browser-approval wire shape (elicitation-mode query parsing, the
/resumeapproval 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-approvaland 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
/resumepage — the leg unit tests structurally cannot reach. The e2e MCP surface gains abrowserelicitation mode and a paused-approval parser.Notes
Testing
typecheck/format:check/lintclean.host-mcp+ the local browser-resume integration test green.First of a three-PR stack (cloud → self-host → Cloudflare self-host).
Stack