Skip to content

feat(cloudflare): hibernate MCP Durable Object sessions#1095

Closed
aryasaatvik wants to merge 9 commits into
UsefulSoftwareCo:mainfrom
aryasaatvik:contrib/cloudflare-mcp-do-hibernation
Closed

feat(cloudflare): hibernate MCP Durable Object sessions#1095
aryasaatvik wants to merge 9 commits into
UsefulSoftwareCo:mainfrom
aryasaatvik:contrib/cloudflare-mcp-do-hibernation

Conversation

@aryasaatvik

@aryasaatvik aryasaatvik commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a hibernatable MCP Durable Object path for Cloudflare Workers by serving MCP transport requests through Cloudflare Agents while preserving Executor's existing MCP server construction and approval behavior.

The key lifecycle change is that browser approval pauses are treated as bounded Durable Object work: active MCP POST responses drain before hibernation, approval waits keep the object alive only for the approval window, and abandoned approvals are declined automatically.

Implementation Shape

Cloudflare Agent Durable Object

packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts

McpAgentSessionDOBase
  -> fetch(request)
  -> route Streamable HTTP MCP requests
  -> create MCP session props from ExecutionContext
  -> track active POST request ids
  -> track pending approval leases by execution id
  -> schedule alarms from active request and approval state
  -> hibernate when no request or approval lease requires residency

The Agent-backed Durable Object owns transport residency. It does not rebuild the MCP tool server contract. It gives Cloudflare the hibernation-aware runtime surface while keeping Executor's MCP server implementation in @executor-js/host-mcp.

MCP Host Pause Hooks

packages/hosts/mcp/src/tool-server.ts

PausedExecutionHooks
  -> paused(executionId)
  -> resuming(executionId)
  -> settled(executionId)

The MCP host emits these hooks at the pause lifecycle boundaries that the Cloudflare Durable Object needs to make residency decisions. The hooks are host-facing lifecycle signals, not plugin APIs.

Execution Introspection

packages/core/execution/src/engine.ts

ExecutionEngine
  -> getPausedExecution(executionId)
  -> report pending approval state to host policy

Cloudflare session alarm policy uses this read-only introspection to distinguish an execution that is still waiting on approval from one that can be allowed to hibernate.

Cloud App Wiring

apps/host-cloudflare
  -> /mcp transport requests use Agent bridge
  -> API and discovery routes stay in the existing app envelope

apps/cloud
  -> uses the same Agent Durable Object base

Request Flow

Worker fetch
  -> route API and discovery requests through the existing app envelope
  -> route /mcp transport requests through the Cloudflare Agent bridge
  -> McpAgentSessionDOBase handles Streamable HTTP
  -> MCP tool server invokes Executor
  -> active POST request id stays tracked until the response drains
  -> Durable Object becomes hibernatable when request and approval state are clear

Approval Pause Flow

MCP tool call requires approval
  -> execution pauses
  -> MCP tool server emits PausedExecutionHooks.paused(executionId)
  -> Durable Object records a pending approval lease
  -> alarm policy keeps the object resident for the approval window
  -> browser approval endpoint resumes or denies the execution
  -> timeout path auto-denies abandoned approvals
  -> MCP tool server emits resuming and settled hooks
  -> active POST response drains
  -> Durable Object can hibernate

Lifecycle Diagram

flowchart TD
  A["Worker /mcp request"] --> B["Cloudflare Agent bridge"]
  B --> C["McpAgentSessionDOBase"]
  C --> D["MCP Streamable HTTP transport"]
  D --> E["MCP tool server"]
  E --> F["Executor engine"]
  F --> G{"Approval required?"}
  G -->|No| H["Drain active POST response"]
  G -->|Yes| I["paused hook"]
  I --> J["Pending approval lease"]
  J --> K{"Decision before timeout?"}
  K -->|Approve or deny| L["resume execution"]
  K -->|Timeout| M["auto-deny"]
  L --> N["resuming and settled hooks"]
  M --> N
  N --> H
  H --> O["No active request ids"]
  O --> P["Durable Object can hibernate"]
Loading

Scope Notes

  • This PR changes Cloudflare MCP session residency, not the MCP tool contract.
  • Discovery and non-transport app routes continue through the existing app envelope.
  • Approval waits are bounded by the approval window plus a small drain margin.
  • OpenAPI-backed annotations use point lookups during tool execution so large specs do not force broad operation listing on the hot path.
  • Cloud metering decorators forward paused-execution introspection so the Cloudflare policy sees the real engine state.

Validation

  • bunx oxfmt --check apps/host-cloudflare/src/mcp/session-durable-object.ts apps/cloud/src/mcp/session-durable-object.ts packages/hosts/mcp/src/tool-server.ts packages/hosts/mcp/src/tool-server.test.ts packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts packages/hosts/cloudflare/src/mcp/session-alarm-policy.ts packages/plugins/openapi/src/sdk/backing.ts
  • bunx oxlint --deny-warnings apps/host-cloudflare/src/mcp/session-durable-object.ts apps/cloud/src/mcp/session-durable-object.ts packages/hosts/mcp/src/tool-server.ts packages/hosts/mcp/src/tool-server.test.ts packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts packages/hosts/cloudflare/src/mcp/session-alarm-policy.ts packages/plugins/openapi/src/sdk/backing.ts
  • bun run --cwd packages/hosts/mcp test src/tool-server.test.ts
  • bun run --cwd packages/hosts/cloudflare test src/mcp/session-alarm-policy.test.ts
  • bun run --cwd packages/plugins/openapi test src/sdk/store.test.ts src/sdk/plugin.test.ts
  • bun run --cwd packages/hosts/mcp typecheck
  • bun run --cwd packages/hosts/cloudflare typecheck
  • bun run --cwd packages/plugins/openapi typecheck
  • bun run --cwd apps/host-cloudflare typecheck
  • bun run --cwd apps/cloud typecheck
  • git diff --check

Expose pausedExecutionCount and hasPausedExecutions on ExecutionEngine so
Cloudflare MCP session DOs can distinguish idle cleanup from paused continuations.
… exist

Add shared alarm policy so idle sessions still destroy after 5 minutes, but
sessions with in-memory paused executions reschedule a 10-minute lease instead
of tearing down the runtime. Preserves the legacy unaddressable-alarm guard.
…ptile)

The extend_paused_lease branch rescheduled the alarm unconditionally, so a
session whose paused execution was never resumed (browser tab closed, client
disconnect) would extend the lease forever and keep the Durable Object alive
and billable indefinitely.

Bound the lease to a single approval window: once idle exceeds
SESSION_TIMEOUT_MS + PAUSED_EXECUTION_LEASE_MS (15 min), the session is torn
down regardless of outstanding paused work. By that point the browser approval
wait (tool-server, 10 min) has already timed out, so the paused execution is no
longer resumable. idleMs grows across extensions because activity is not marked
during the wait, so decideSessionAlarm stays pure.

Also add the missing coverage: idle_within_timeout wins regardless of paused
work, and the new cap forces destroy_idle_session.
Add host-owned paused execution hooks so Cloudflare MCP sessions can hold a bounded pending-approval lease, keep active POST requests alive until their response drains, and release hibernation blockers on resume or timeout.

Also bound the approval wait window and resolve OpenAPI-backed annotations with point lookups so large specs do not reset the Durable Object before returning a pause envelope.

This is the upstream port of the core runtime pieces from #56. The execution-history payload compaction from that PR is omitted because this upstream branch does not contain that plugin.
Remove MCP host console logging wrappers and rely on existing debug logging plus span annotations for pause lifecycle state.

Keep Cloudflare Durable Object logs focused on exceptional boundary events by dropping happy-path active POST and pending approval lease messages.
@aryasaatvik aryasaatvik marked this pull request as ready for review June 24, 2026 00:07
@greptile-apps

greptile-apps Bot commented Jun 24, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces the existing Cloudflare Durable Object MCP transport with a hibernatable McpAgent-backed path (McpAgentSessionDOBase), keeping the executor server construction and browser-approval flow intact while allowing the DO to sleep between requests.

  • Introduces McpAgentSessionDOBase in @executor-js/cloudflare that wraps McpAgent and manages bounded per-execution keepAlive leases, browser approval storage, and session alarm policy; both apps/host-cloudflare and apps/cloud subclass it.
  • Adds PausedExecutionHooks to the MCP tool-server so hosts receive pause/resume-started/resume-settled boundaries, enabling precise keepAlive windows instead of a session-wide lease.
  • Replaces the broad session keepAlive with bounded pendingApprovalLeases (one per paused execution, auto-expired after PAUSED_APPROVAL_TIMEOUT_MS), and drains active POST responses before hibernation via waitForActivePostResponseDrain.

Confidence Score: 5/5

The change is safe to merge; the double-resume guard called out in the prior thread is present, approval lease bounds are well-tested, and the Effect fiber cleanup paths are sound.

The hibernation lifecycle — bounded keepAlive leases, active-POST drain, approval-waiter resolution, and alarm policy — is carefully coordinated and covered by unit and e2e tests. The guard against calling engine.resume when an active waiter already holds the execution is in place. No correctness gaps were found in the new paths.

No files require special attention; the most complex logic in agent-session-durable-object.ts tracks cleanly through the keepAlive lifecycle.

Important Files Changed

Filename Overview
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts New 781-line base class implementing the hibernatable DO path: bounded pending-approval leases, active-POST drain, browser-approval waiter map, and lifecycle hooks; guard against double-resume on timeout is present.
packages/hosts/mcp/src/tool-server.ts Adds PausedExecutionHooks, PAUSED_APPROVAL_TIMEOUT_MS export, resumeWithLifecycle wrapper, and narrows browser-approval wait timeout from 10 min to PAUSED_APPROVAL_TIMEOUT_MS + 1 s; pause/resume annotations added at all three execution sites.
packages/hosts/cloudflare/src/mcp/session-alarm-policy.ts Clean pure-function alarm policy: idle_within_timeout / extend_paused_lease (one extension, capped at MAX_PAUSED_SESSION_IDLE_MS) / destroy_idle_session; well-covered by companion tests.
apps/host-cloudflare/src/mcp/agent-handler.ts New edge-layer auth + session-owner validation before forwarding to the hibernatable Agent bridge; mirrors the cloud version closely.
apps/host-cloudflare/src/mcp/index.ts Removes the old makeCloudflareMcpSeams / session-store seam; approval handler updated to use idFromName('streamable-http:${sessionId}') matching the new agent bridge naming.
packages/plugins/openapi/src/sdk/backing.ts Switches resolveOpenApiBackedAnnotations from listing all operations to point lookups per tool row, avoiding a full scan of large specs on every tool execution.
apps/host-cloudflare/src/worker.ts Splits isolate-level handler into app + mcp paths; /mcp now routed to mcpAgentHandler with the platform ExecutionContext.
packages/core/execution/src/engine.ts Adds pausedExecutionCount and hasPausedExecutions to ExecutionEngine; both are simple synchronous Map.size reads.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client as MCP Client
    participant Worker as CF Worker
    participant Agent as McpAgentSessionDOBase (DO)
    participant ToolServer as Tool Server (in-DO)
    participant Browser as Browser Approval UI

    Client->>Worker: POST /mcp (initialize)
    Worker->>Agent: McpAgent.serve() — session props in ctx
    Agent->>Agent: init() → resolveSessionMeta + buildMcpServer
    Agent-->>Client: session-id header + response

    Client->>Worker: POST /mcp (execute tool)
    Worker->>Agent: onConnect (keepAliveWhile active POST)
    Agent->>ToolServer: executeWithPause()
    ToolServer-->>Agent: "outcome=paused → onExecutionPaused()"
    Agent->>Agent: startPendingApprovalLease → keepAlive() held
    Agent-->>Client: paused result (SSE/JSON)
    Agent->>Agent: waitForActivePostResponseDrain → keepAliveWhile released

    Browser->>Agent: resumeExecutionForApproval RPC
    Agent->>Agent: recordApprovalResponse → Deferred resolved
    Agent-->>Browser: resumeApprovalResult

    Client->>Worker: POST /mcp (resume tool)
    Worker->>Agent: onConnect
    Agent->>ToolServer: resumeWithLifecycle → onResumeStarted
    ToolServer->>Agent: engine.resume()
    ToolServer-->>Agent: "outcome=completed → onResumeSettled"
    Agent->>Agent: finishPendingApprovalResume → disposeKeepAlive()
    Agent-->>Client: completed result

    Note over Agent: keepAlive released → DO may hibernate

    Agent->>Agent: alarm() fires (idle timeout)
    Agent->>Agent: decideSessionAlarm → destroy_idle_session
    Agent->>Agent: destroy() → closeRuntime + super.destroy()
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Client as MCP Client
    participant Worker as CF Worker
    participant Agent as McpAgentSessionDOBase (DO)
    participant ToolServer as Tool Server (in-DO)
    participant Browser as Browser Approval UI

    Client->>Worker: POST /mcp (initialize)
    Worker->>Agent: McpAgent.serve() — session props in ctx
    Agent->>Agent: init() → resolveSessionMeta + buildMcpServer
    Agent-->>Client: session-id header + response

    Client->>Worker: POST /mcp (execute tool)
    Worker->>Agent: onConnect (keepAliveWhile active POST)
    Agent->>ToolServer: executeWithPause()
    ToolServer-->>Agent: "outcome=paused → onExecutionPaused()"
    Agent->>Agent: startPendingApprovalLease → keepAlive() held
    Agent-->>Client: paused result (SSE/JSON)
    Agent->>Agent: waitForActivePostResponseDrain → keepAliveWhile released

    Browser->>Agent: resumeExecutionForApproval RPC
    Agent->>Agent: recordApprovalResponse → Deferred resolved
    Agent-->>Browser: resumeApprovalResult

    Client->>Worker: POST /mcp (resume tool)
    Worker->>Agent: onConnect
    Agent->>ToolServer: resumeWithLifecycle → onResumeStarted
    ToolServer->>Agent: engine.resume()
    ToolServer-->>Agent: "outcome=completed → onResumeSettled"
    Agent->>Agent: finishPendingApprovalResume → disposeKeepAlive()
    Agent-->>Client: completed result

    Note over Agent: keepAlive released → DO may hibernate

    Agent->>Agent: alarm() fires (idle timeout)
    Agent->>Agent: decideSessionAlarm → destroy_idle_session
    Agent->>Agent: destroy() → closeRuntime + super.destroy()
Loading

Reviews (2): Last reviewed commit: "fix(mcp): guard approval timeout races" | Re-trigger Greptile

Comment thread packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts
Comment thread apps/host-cloudflare/src/worker.ts
Handle malformed active POST metadata without failing the Agent connection.

Avoid overwriting a stored browser approval on timeout, and only resume timed-out executions directly when no MCP waiter is already registered.
@aryasaatvik

Copy link
Copy Markdown
Contributor Author

Additional downstream context from my fork: this PR is the foundation I am using for the deployed Cloudflare self-hosted Executor instance.

High-level shape:

Worker /mcp request
  -> Cloudflare Agent Streamable HTTP bridge
  -> McpAgentSessionDOBase
  -> host-specific McpSessionDO
  -> MCP tool server
  -> execution engine
  -> approval pause, if needed
  -> bounded pending-approval lease
  -> drain active POST
  -> hibernate

AST outline of the downstream host binding:

McpSessionDO extends McpAgentSessionDOBase
  -> openSessionDb()
  -> resolveSessionMeta()
  -> buildMcpServer()

Fork permalinks:

Call stack for approval UI:

GET /api/mcp-sessions/:session/executions/:execution
  -> env.MCP_SESSION.idFromName("streamable-http:" + session)
  -> stub.getPausedExecutionForApproval(...)
  -> DO restores runtime if needed
  -> engine.getPausedExecution(executionId)
  -> browser receives formatted approval payload

POST /api/mcp-sessions/:session/executions/:execution/resume
  -> stub.resumeExecutionForApproval(...)
  -> record approval response
  -> waiting MCP tool call resumes

The reason I see this as foundational is that it keeps the MCP transport/housing concern separate from the host-specific app. Once merged, more Cloudflare host features can build on the same hibernatable DO path without changing the MCP tool contract.

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