machine-bridge-mcp 3.0.0-beta.16 → 3.0.0-beta.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.0.0-beta.17 - 2026-07-26
4
+
5
+ ### Fixed
6
+
7
+ - Serve `/healthz`, `/`, and CORS preflight from the outer Worker so activation and doctor checks no longer consume Durable Object free-tier request volume. Durable Object free-tier exhaustion now returns a structured `503 durable_object_quota_exceeded` instead of Cloudflare error 1101.
8
+
9
+ ### Durable Object stream request amplification fix
10
+
11
+ - Replace the outer Worker's time-proportional internal Durable Object poll loop with a fixed two-request terminal path: one authenticated descriptor `prepare`, then one hibernatable WebSocket `subscribe`.
12
+ - Add `mcp-stream-channel.ts` so `BridgeRoom` accepts a single stream subscriber through `DurableObjectState.acceptWebSocket()`, replaces stale resume subscribers, rechecks storage after registration to close the completion race, and pushes exactly one terminal JSON-RPC message.
13
+ - Persist-ready notifications are fire-and-forget from `McpResumptionStore`; if persistence fails, the current online subscriber can still receive the transient terminal result while recovery storage keeps failure semantics.
14
+ - Keep daemon candidate cleanup from treating stream-subscriber sockets as daemon candidates, and reject client-to-DO data on receive-only stream subscribers.
15
+ - Fix the outer subscription waiter so invalid terminal payloads reject instead of leaving the SSE completion Promise permanently unsettled.
16
+ - Extend deterministic infrastructure coverage for the fixed two-request budget, obsolete poll-mode rejection, subscriber replacement, registration races, immediate-completion paths, protocol errors, and non-daemon socket isolation. Update architecture, engineering, testing, audit, and operations contracts to describe subscribe push delivery instead of short pending/terminal polls.
17
+
3
18
  ## 3.0.0-beta.16 - 2026-07-25
4
19
 
5
20
  ### Pending-call recovery and verified handover
@@ -30,6 +30,6 @@
30
30
  "action": {
31
31
  "default_title": "Machine Bridge Browser"
32
32
  },
33
- "version_name": "3.0.0-beta.16",
33
+ "version_name": "3.0.0-beta.17",
34
34
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxryYkpZhq8+VAQLHcGS9BAHQcyKX8RHGIpIwvtIVRU/rcOcE0bNdnM0aZJ/h6xWQsGDHlhvjT2+1aJaAn/9k8473BRWajzVXld961CdHYVFVHoce2hHiSJ0xydWrHMMZhAm0mN0UzjEpgZ0tMw209efcZHIvSwuxhteZMRy4kyiVjwFlOf5oXFCxRuCJnPj3AK9CmCf4XgEBuPIJ0TZmjGHOOdBvJmbCNnAWXYEo5/mf7MfCGhV4IJ1hNuhpoNQfOFKMUcw9/v/IpT62XpfXdGYTfGYCmCjC+gntK1spbkr2P4/2+sYMQtLpse71mpSNGXfcf3abU55Vpn+gncSxRQIDAQAB"
35
35
  }
@@ -106,7 +106,7 @@ The stdio server implements newline-delimited JSON-RPC over stdin/stdout. It neg
106
106
 
107
107
  ### Cloudflare Worker and Durable Object
108
108
 
109
- All requests for a deployed Worker route to one named Durable Object. It owns:
109
+ Public `/healthz`, `/`, and CORS preflight are answered by the outer Worker without Durable Object requests, so activation and doctor checks do not consume free-tier DO volume. All other requests route to one named Durable Object. It owns:
110
110
 
111
111
  - OAuth clients, authorization codes, hashed access-token records, an independently versioned hashed refresh-token store, and throttling metadata;
112
112
  - one active end-to-end-verified daemon WebSocket plus bounded candidate and probing sockets;
@@ -114,9 +114,9 @@ All requests for a deployed Worker route to one named Durable Object. It owns:
114
114
  - a bounded in-memory map of pending daemon calls, with monotonic operation/reconnect deadlines projected onto Durable Object alarms and rechecked at every event boundary;
115
115
  - bounded resumable MCP delivery metadata and terminal responses for recently disconnected SSE clients.
116
116
 
117
- `BridgeRoom` owns Durable Object routing, MCP authorization/dispatch, daemon WebSocket lifecycle, pending relay-call composition, cancellation, and resumable state. `mcp-stream-proxy.ts` owns the outer-Worker transport adapter: it strips public internal-control headers, obtains a bounded authenticated descriptor, creates the client-facing SSE stream, and polls terminal state through short immediate service-binding requests. `mcp-access.ts` owns shared Bearer/DPoP authorization for POST and recovery GET. `mcp-resumption-http.ts` owns recovery routing and signed session/protocol binding and returns descriptors rather than a long-lived response. `mcp-resumption.ts` owns stream admission, transaction ordering, immediate pending/terminal polls, expiry, replay, and lifecycle state; `mcp-resumption-records.ts` owns the compact metadata index, terminal-message bounds, serialization, and SHA-256 integrity metadata. `mcp-stream.ts` owns SSE sequence-zero/sequence-one framing and heartbeats. `mcp-jsonrpc.ts` owns JSON-RPC shape validation, result/error framing, MCP tool-result projection, session-instruction bounds, and protocol-header validation. `websocket-protocol.ts` owns record validation plus best-effort send/close/rejection helpers. `OAuthController` owns OAuth-store pruning, registration throttling, authorization submission, account-admin routing, token exchange, access-token verification, and the serialization queue for OAuth mutations. Worker-internal TypeScript imports use explicit `.ts` specifiers and JSON import attributes, so the same modules are directly executable under the pinned Node runtime and bundled by Wrangler.
117
+ `BridgeRoom` owns Durable Object routing, MCP authorization/dispatch, daemon WebSocket lifecycle, pending relay-call composition, cancellation, and resumable state. `mcp-stream-proxy.ts` owns the outer-Worker transport adapter: it strips public internal-control headers, obtains a bounded authenticated descriptor, creates the client-facing SSE stream, and waits for the terminal result through one authenticated internal WebSocket subscription. `mcp-stream-channel.ts` owns Durable Object subscriber registration, single-subscriber replacement, and hibernation-safe terminal push. `mcp-access.ts` owns shared Bearer/DPoP authorization for POST and recovery GET. `mcp-resumption-http.ts` owns recovery routing and signed session/protocol binding and returns descriptors rather than a long-lived response. `mcp-resumption.ts` owns stream admission, transaction ordering, immediate pending/terminal polls, expiry, replay, and lifecycle state; `mcp-resumption-records.ts` owns the compact metadata index, terminal-message bounds, serialization, and SHA-256 integrity metadata. `mcp-stream.ts` owns SSE sequence-zero/sequence-one framing and heartbeats. `mcp-jsonrpc.ts` owns JSON-RPC shape validation, result/error framing, MCP tool-result projection, session-instruction bounds, and protocol-header validation. `websocket-protocol.ts` owns record validation plus best-effort send/close/rejection helpers. `OAuthController` owns OAuth-store pruning, registration throttling, authorization submission, account-admin routing, token exchange, access-token verification, and the serialization queue for OAuth mutations. Worker-internal TypeScript imports use explicit `.ts` specifiers and JSON import attributes, so the same modules are directly executable under the pinned Node runtime and bundled by Wrangler.
118
118
 
119
- The Worker verifies OAuth, validates MCP envelopes and optional protocol headers, converts `tools/call` into WebSocket messages, correlates explicit cancellation by access-token hash, signed MCP session, and JSON-RPC ID, and formats text/structured/image results. An HTTP/SSE disconnect is transport disposal, not MCP cancellation. For streamed daemon tools, `BridgeRoom` commits a recovery record, registers an event-settled pending call, sends the daemon envelope, and immediately returns an internal descriptor without retaining a terminal Promise. The later WebSocket result, explicit cancellation, timeout, send failure, or reconnect-grace expiry persists the terminal JSON-RPC envelope. JSON-only calls retain the ordinary Promise path. The outer Worker sends `stream:0`, heartbeats, and `stream:1`; bounded short polls return pending or terminal state immediately. Authenticated `GET /mcp` with `Last-Event-ID` resumes only the original OAuth-token/MCP-session stream; POST always creates new work. Public requests cannot select internal descriptor/poll modes because the outer boundary removes those headers before forwarding. At most 64 records and 1.5 MiB of terminal JSON per record are retained for two minutes. The recovery store tracks active stream identifiers, not live Promises; a transient terminal map is used only when persistence fails. If the Durable Object restarts with a pending record but no active owner, recovery reports that side effects may have occurred and requires reconciliation before retry. It has no local filesystem or process API.
119
+ The Worker verifies OAuth, validates MCP envelopes and optional protocol headers, converts `tools/call` into WebSocket messages, correlates explicit cancellation by access-token hash, signed MCP session, and JSON-RPC ID, and formats text/structured/image results. An HTTP/SSE disconnect is transport disposal, not MCP cancellation. For streamed daemon tools, `BridgeRoom` commits a recovery record, registers an event-settled pending call, sends the daemon envelope, and immediately returns an internal descriptor without retaining a terminal Promise. The later WebSocket result, explicit cancellation, timeout, send failure, or reconnect-grace expiry persists the terminal JSON-RPC envelope. JSON-only calls retain the ordinary Promise path. The outer Worker sends `stream:0`, heartbeats, and `stream:1`; the Durable Object accepts one hibernatable subscriber WebSocket per stream and pushes the terminal JSON-RPC envelope once. Authenticated `GET /mcp` with `Last-Event-ID` resumes only the original OAuth-token/MCP-session stream; POST always creates new work. Public requests cannot select internal descriptor/subscribe modes because the outer boundary removes those headers before forwarding. At most 64 records and 1.5 MiB of terminal JSON per record are retained for two minutes. The recovery store tracks active stream identifiers, not live Promises; a transient terminal map is used only when persistence fails. If the Durable Object restarts with a pending record but no active owner, recovery reports that side effects may have occurred and requires reconciliation before retry. It has no local filesystem or process API.
120
120
 
121
121
 
122
122
  ### Daemon device authentication
@@ -172,7 +172,7 @@ Remote OAuth binds each code, access token, and refresh token to a named Machine
172
172
  7. The MCP client initializes against the sole current protocol version; an obsolete client must upgrade rather than enter a legacy execution path. The Worker returns a stateless HMAC-bound `MCP-Session-Id`, and later request/cancellation correlation is scoped by OAuth token, MCP session, JSON-RPC id type, and id value. Two clients may therefore reuse the same JSON-RPC id concurrently without collision. Sessionless POSTs remain independent and are not inserted into a token-global cancellation index. When the daemon advertises `session_bootstrap`, the Worker requests bounded local instructions and appends them to the initialization result; failure degrades to static instructions.
173
173
  8. A new daemon first authenticates as a bounded `probing` socket. The Worker sends a random `relay_probe`; the local runtime returns it through the normal session-bound result-delivery path; only the matching result produces `ready_ack`, promotion to the active daemon, and safe replacement of an incumbent connection.
174
174
  9. `tools/list` is derived only from the active end-to-end-verified daemon; without one, only `server_info` is advertised.
175
- 10. `tools/call` receives a random relay call ID and is bound to the current daemon socket, that daemon process's ephemeral instance identifier, and the authenticated client request key. When the client accepts `text/event-stream`, `BridgeRoom` commits recovery state, registers an event-settled pending call, sends the daemon envelope, and returns a bounded descriptor immediately; the outer Worker owns the SSE priming frame, keepalives, and terminal polling. No unresolved terminal Promise or Durable Object `waitUntil` owns the dispatch. JSON-only clients retain the single terminal response.
175
+ 10. `tools/call` receives a random relay call ID and is bound to the current daemon socket, that daemon process's ephemeral instance identifier, and the authenticated client request key. When the client accepts `text/event-stream`, `BridgeRoom` commits recovery state, registers an event-settled pending call, sends the daemon envelope, and returns a bounded descriptor immediately; the outer Worker owns the SSE priming frame, keepalives, and one internal terminal subscription. No unresolved terminal Promise or Durable Object `waitUntil` owns the dispatch. JSON-only clients retain the single terminal response.
176
176
  11. The runtime validates policy and arguments, executes the tool, and returns a bounded result. Closing or losing the HTTP response stream only makes that stream unwritable; it is not an MCP cancellation and does not remove the pending request.
177
177
  12. If the socket remains ready, the Durable Object accepts the result only from that socket. If it drops, the Worker detaches the pending call for at most two minutes and accepts completion only after a replacement socket with the same daemon-process identifier has passed the end-to-end readiness probe. The local runtime preserves the operation and queues a completion over the same shared interval. The Worker pauses the record's remaining normal deadline while detached and resumes it after same-instance rebinding, so connected calls are not granted an unconditional recovery extension.
178
178
  13. Only a matching session-scoped `notifications/cancelled` request removes the pending indexes and sends best-effort cancellation to a connected daemon. Local completion that races with explicit cancellation is discarded. On every readiness handover, the Worker first sends an authoritative bounded `resume_calls` set; the runtime cancels active calls and queued results absent from that set before accepting `ready_ack`. A request explicitly cancelled while disconnected therefore cannot be revived by a fast reconnect.
package/docs/AUDIT.md CHANGED
@@ -32,7 +32,7 @@ The exact beta.13 tarball was activated through the owner command and converged
32
32
 
33
33
  The same live run exposed a release-blocking production scheduling defect that the local Wrangler integration had not represented. While `BridgeRoom` directly returned an open SSE response, later requests routed to the same Durable Object—including `server_info` and the authoritative session-scoped `notifications/cancelled` notification—did not enter until the stream ended. The daemon call eventually terminated through its own boundary, but Worker observability did not record a successful cancellation. Beta.13 therefore has no acceptance record, is not pushed or published, and is explicitly blocked.
34
34
 
35
- Beta.14 separates client transport ownership from durable state ownership. The outer stateless Worker creates the public SSE stream. `BridgeRoom` performs OAuth/DPoP authorization, signed MCP-session validation, stream admission, daemon dispatch, explicit cancellation, and terminal persistence, then returns a small internal descriptor. The outer Worker uses short service-binding polls: pending state returns immediately with HTTP 202; terminal state returns buffered JSON; missing or expired state fails closed. No internal poll or descriptor request remains open in the Durable Object. Publicly supplied internal-control headers are stripped before every service-binding forward, so callers cannot select the unauthenticated internal poll path.
35
+ Beta.14 separates client transport ownership from durable state ownership. The outer stateless Worker creates the public SSE stream. `BridgeRoom` performs OAuth/DPoP authorization, signed MCP-session validation, stream admission, daemon dispatch, explicit cancellation, and terminal persistence, then returns a small internal descriptor. The outer Worker uses one service-binding WebSocket subscription after the authenticated descriptor: pending streams hibernate under `acceptWebSocket()`, terminal state is pushed once, and missing or expired state fails closed. No internal request remains open in the Durable Object for the life of a long tool call. Publicly supplied internal-control headers are stripped before every service-binding forward, so callers cannot select the unauthenticated internal subscribe path.
36
36
 
37
37
  The real Wrangler regression keeps the original SSE response open, confirms a concurrent `server_info` sees the pending call, sends `notifications/cancelled`, observes the matching daemon `cancel_call`, and receives a cancelled terminal result on the original stream. Existing disconnect/recovery, wrong-session rejection, sequence-one acknowledgement, CORS, persistence faults, capacity, integrity, and oversized-message tests remain in force. This correction requires a new exact beta.14 candidate, owner activation, and repeated live verification; beta.13 activation evidence cannot be reused.
38
38
 
@@ -21,7 +21,7 @@ This document records project-wide decisions that must survive individual fixes,
21
21
  15. **Ambiguous health is not permission to repeat a remote write.** A successful Wrangler deployment is recorded before secondary health verification. Timeout, proxy, TLS, network, and temporary service failures preserve the deployment fingerprint and fail for diagnosis; only bounded evidence of a stale identity/version permits automatic same-name redeployment. Changing the Worker name is an explicit remote-resource transition, not a retry strategy.
22
22
  16. **Execution continuity and delivery continuity are separate proof obligations.** Keeping work alive after a client transport closes is insufficient unless the same authenticated principal can recover a terminal result or a durable handle. Fresh requests and replay endpoints must remain separate so recovery cannot accidentally duplicate a non-idempotent operation.
23
23
  17. **Remote compound commands are not persistence evidence.** When a remote edit and a long test share one relay call, a transport interruption can obscure whether the edit completed. High-impact writes must be followed by an independent read of stable anchors or a Git diff before tests and conclusions rely on them.
24
- 18. **Durable state owners do not retain cross-event terminal Promises.** A Durable Object that must accept cancellation, status, or recovery requests cannot retain the public SSE response, an internal request waiting for completion, or an unresolved Promise owned by the initiating fetch event. The outer Worker owns streaming; streamed daemon calls are registered and returned immediately, then settled by later WebSocket, cancellation, timeout, send-failure, or reconnect-expiry events. Descriptor and poll requests remain short, authenticated at admission, and unreachable through caller-supplied internal headers.
24
+ 18. **Durable state owners do not retain cross-event terminal Promises.** A Durable Object that must accept cancellation, status, or recovery requests cannot retain the public SSE response, an internal request waiting for completion, or an unresolved Promise owned by the initiating fetch event. The outer Worker owns streaming; streamed daemon calls are registered and returned immediately, then settled by later WebSocket, cancellation, timeout, send-failure, or reconnect-expiry events. Descriptor requests remain short; terminal delivery uses one authenticated hibernatable WebSocket subscription. Both are admitted only on the internal service-binding path and are unreachable through caller-supplied internal headers.
25
25
 
26
26
  A proposed change that conflicts with an invariant requires an explicit owner decision and corresponding documentation update. It must not be hidden inside an unrelated refactor.
27
27
 
@@ -12,6 +12,8 @@ machine-mcp service status
12
12
 
13
13
  ### Worker deployment and health convergence
14
14
 
15
+ `/healthz` and `/` are answered by the outer Worker and do not consume Durable Object request volume. If MCP or daemon routes return `503 durable_object_quota_exceeded` (or Cloudflare 1101 with Durable Objects free-tier exhaustion in Worker tails), wait for the daily UTC free-tier reset or move the account off the free DO plan; do not treat that as a failed script deploy when `/healthz` still reports the expected version.
16
+
15
17
  Wrangler upload and public health verification are two separate observations. Once Wrangler reports a successful deployment and supplies the `workers.dev` URL, Machine Bridge immediately records that URL together with the exact deployment fingerprint and package version. It then verifies `/healthz` through the standard `HTTPS_PROXY`/`HTTP_PROXY` and `NO_PROXY` environment route. If that secondary probe times out or encounters a proxy, TLS, network, or temporary HTTP 5xx failure, startup stops with an actionable error, but the successful deployment evidence remains. The next ordinary start verifies the same Worker and does not repeat the upload.
16
18
 
17
19
  Automatic redeployment is limited to bounded health evidence that the recorded endpoint is genuinely stale: a persistent package-version mismatch, an unexpected Machine Bridge identity, or a persistent `404`/`410`. Unreachability is not proof of absence. `--force-worker` remains the explicit override when an operator deliberately wants an upload despite matching state.
@@ -53,7 +55,7 @@ Machine Bridge supports concurrent calls: the Worker admits up to 32 pending dae
53
55
 
54
56
  `server_info.worker.pending_calls` reports `active`, `detached`, `request_keys`, `maximum`, `oldest_ms`, and `by_tool`. `worker.sockets_live` separately reports `authenticated`, `probing`, `ready`, and `candidates`; only `ready` sockets contribute to `daemon.connected` and tool advertisement. A nonzero `active` count means work is in flight, not that the bridge is locked. `detached > 0` means a daemon socket was lost and those requests are inside the bounded two-minute same-instance reconnect window. Calls for simple reads and probes should continue while another independent process call runs. Only explicit session-scoped MCP cancellation, timeout, or reconnect-grace expiry removes the pending record and its request key; an HTTP response disconnect is not cancellation. A daemon-socket closure detaches only calls assigned to that socket; the same daemon process can reclaim them after completing readiness, while another process cannot. A verified same-instance replacement transfers both detached and still-attached calls before the incumbent closes. Normal and reconnect deadlines have three enforcement paths: monotonic in-event timers, a Durable Object alarm, and an overdue sweep at the next HTTP/WebSocket event. Therefore `detached > 0` with `oldest_ms` materially beyond the two-minute grace is a lifecycle defect rather than normal recovery. Grace expiry rejects the request and cancels the local ordinary operation. Refreshing a chat page is not the recovery mechanism and should not be required.
55
57
 
56
- For Streamable HTTP clients such as ChatGPT that advertise `text/event-stream`, the outer Worker returns an immediate sequence-zero SSE event identifier and a keepalive comment every ten seconds until the terminal sequence-one JSON-RPC result. `BridgeRoom` never owns the long-lived public stream or an unresolved terminal Promise. Stream initiation commits recovery state, registers the daemon call, sends it, and returns a descriptor; a later WebSocket result, explicit cancellation, timeout, send failure, or reconnect-grace expiry writes the terminal result. Short pending/terminal polls therefore coexist with concurrent `server_info`, recovery, and session-scoped `notifications/cancelled` requests while SSE remains open. Caller-supplied internal stream headers are removed at the public boundary. If the client or an intermediary closes the stream, Machine Bridge keeps the bounded operation alive; only `notifications/cancelled` carries cancellation semantics. A compatible host resumes the original stream with authenticated `GET /mcp`, the original `MCP-Session-Id`, and `Last-Event-ID`; it must not repeat the POST. Recovery records are token/session-bound, retained for at most two minutes, limited to 64 streams, and persist at most 1.5 MiB of terminal JSON. Error `-32002` means the online result exceeded the replay budget; `-32003` means the Worker restarted before it could persist a terminal result and the operation may already have produced side effects; reconcile state before retrying. Error `-32005` means stored replay data failed integrity validation.
58
+ For Streamable HTTP clients such as ChatGPT that advertise `text/event-stream`, the outer Worker returns an immediate sequence-zero SSE event identifier and a keepalive comment every ten seconds until the terminal sequence-one JSON-RPC result. `BridgeRoom` never owns the long-lived public stream or an unresolved terminal Promise. Stream initiation commits recovery state, registers the daemon call, sends it, and returns a descriptor; a later WebSocket result, explicit cancellation, timeout, send failure, or reconnect-grace expiry writes the terminal result. One internal hibernatable WebSocket subscription therefore coexists with concurrent `server_info`, recovery, and session-scoped `notifications/cancelled` requests while SSE remains open, without creating a request per poll interval. Caller-supplied internal stream headers are removed at the public boundary. If the client or an intermediary closes the stream, Machine Bridge keeps the bounded operation alive; only `notifications/cancelled` carries cancellation semantics. A compatible host resumes the original stream with authenticated `GET /mcp`, the original `MCP-Session-Id`, and `Last-Event-ID`; it must not repeat the POST. Recovery records are token/session-bound, retained for at most two minutes, limited to 64 streams, and persist at most 1.5 MiB of terminal JSON. Error `-32002` means the online result exceeded the replay budget; `-32003` means the Worker restarted before it could persist a terminal result and the operation may already have produced side effects; reconcile state before retrying. Error `-32005` means stored replay data failed integrity validation.
57
59
 
58
60
  `server_info.worker.observability.calls.unmatched_results` counts results that reached the Worker after their pending record was already removed. A small increase can accompany cancellation or timeout races, especially during mixed-version upgrade convergence; sustained growth together with old pending calls indicates incompatible components or a lifecycle defect. The counter contains no tool arguments or result data.
59
61
 
package/docs/TESTING.md CHANGED
@@ -159,4 +159,4 @@ The stdio integration test also sends an oversized line, verifies bounded reject
159
159
 
160
160
  `npm run mcp-resumption:test` directly exercises stream cursor parsing, OAuth-token/MCP-session isolation, immediate pending/terminal polls, active and completed replay, Worker-restart ambiguity, result-size fallback, SHA-256 tamper detection, transient persistence failure, expiry, capacity, and completed-record eviction.
161
161
 
162
- `npm run worker-runtime-infrastructure:test` verifies outer-Worker stream ownership, stripping of caller-supplied internal headers, bounded descriptor/poll adaptation, sequence-zero/sequence-one framing, poll-error closure, and the shared two-minute/64-stream/1.5-MiB contract. It also models the production event-lifecycle boundary: event-mode registration must return without a terminal Promise or early settlement, while later success, daemon rejection, explicit cancellation, timeout, send failure, result transformation, persistence failure, and same-instance reconnect each produce one terminal result and remove pending indexes. Deadline tests deliberately use a scheduler that never fires callbacks, advance the monotonic clock, and prove that event-boundary sweeps expire both attached operation deadlines and detached reconnect deadlines without leaking request keys. A direct runtime-alarm coordinator test verifies earliest-pending scheduling, alarm removal when no deadline remains, event-entry expiry before rescheduling, and bounded reporting when Durable Object alarm storage fails. The same suite also proves that direct same-instance handover transfers an attached call and preserves its remaining timeout budget. `npm run worker:integration-test` performs the real Wrangler path: keep SSE open while a concurrent `server_info` succeeds and explicit cancellation reaches the matching daemon call; connect a verified same-instance replacement while the incumbent still owns an in-flight call and prove transfer occurs before incumbent close; disconnect after sequence zero; reject another session; recover with GET plus `Last-Event-ID`; and prove a sequence-one acknowledgement is not delivered twice. Managed-job integration treats `__proto__`, `constructor`, `toString`, and `valueOf` environment/resource-map keys as ordinary own data while retaining duplicate-key rejection. Static architecture checks forbid a stream-initiation `dispatchJsonRpc` Promise, `resumption.attach`, Durable Object `waitUntil`, or Promise-valued recovery state. The parser accumulates complete SSE events and does not assume network chunk boundaries. CORS coverage requires both `DPoP` and `Last-Event-ID`.
162
+ `npm run worker-runtime-infrastructure:test` verifies outer-Worker stream ownership, stripping of caller-supplied internal headers, bounded descriptor/subscribe adaptation, fixed two-request Durable Object budgets, sequence-zero/sequence-one framing, subscription-error closure, subscriber replacement, registration races, non-daemon socket isolation, and the shared two-minute/64-stream/1.5-MiB contract. It also models the production event-lifecycle boundary: event-mode registration must return without a terminal Promise or early settlement, while later success, daemon rejection, explicit cancellation, timeout, send failure, result transformation, persistence failure, and same-instance reconnect each produce one terminal result and remove pending indexes. Deadline tests deliberately use a scheduler that never fires callbacks, advance the monotonic clock, and prove that event-boundary sweeps expire both attached operation deadlines and detached reconnect deadlines without leaking request keys. A direct runtime-alarm coordinator test verifies earliest-pending scheduling, alarm removal when no deadline remains, event-entry expiry before rescheduling, and bounded reporting when Durable Object alarm storage fails. The same suite also proves that direct same-instance handover transfers an attached call and preserves its remaining timeout budget. `npm run worker:integration-test` performs the real Wrangler path: keep SSE open while a concurrent `server_info` succeeds and explicit cancellation reaches the matching daemon call; connect a verified same-instance replacement while the incumbent still owns an in-flight call and prove transfer occurs before incumbent close; disconnect after sequence zero; reject another session; recover with GET plus `Last-Event-ID`; and prove a sequence-one acknowledgement is not delivered twice. Managed-job integration treats `__proto__`, `constructor`, `toString`, and `valueOf` environment/resource-map keys as ordinary own data while retaining duplicate-key rejection. Static architecture checks forbid a stream-initiation `dispatchJsonRpc` Promise, `resumption.attach`, Durable Object `waitUntil`, or Promise-valued recovery state. The parser accumulates complete SSE events and does not assume network chunk boundaries. CORS coverage requires both `DPoP` and `Last-Event-ID`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "machine-bridge-mcp",
3
- "version": "3.0.0-beta.16",
3
+ "version": "3.0.0-beta.17",
4
4
  "description": "Cross-client MCP bridge for local agent context, structured browser and application automation, files, Git, processes, resources, and durable jobs over stdio or OAuth relay.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,7 +24,8 @@ interface WebSocketContext {
24
24
  }
25
25
 
26
26
  export class DaemonSocketRegistry {
27
- constructor(private readonly context: WebSocketContext) {}
27
+ private readonly context: WebSocketContext;
28
+ constructor(context: WebSocketContext) { this.context = context; }
28
29
 
29
30
  attachment(socket: WebSocket): DaemonAttachment | undefined {
30
31
  const raw = socket.deserializeAttachment();
@@ -57,7 +58,10 @@ export class DaemonSocketRegistry {
57
58
  }
58
59
 
59
60
  nonReadySockets(): WebSocket[] {
60
- return this.context.getWebSockets().filter((socket) => this.attachment(socket)?.role !== "daemon" && socket.readyState === WebSocket.OPEN);
61
+ return this.context.getWebSockets().filter((socket) => {
62
+ const role = this.attachment(socket)?.role;
63
+ return Boolean(role && role !== "daemon" && socket.readyState === WebSocket.OPEN);
64
+ });
61
65
  }
62
66
 
63
67
  beginCandidate(
@@ -81,13 +85,8 @@ export class DaemonSocketRegistry {
81
85
 
82
86
  beginProbe(socket: WebSocket, values: { connectedAt: string; probeId: string; instanceId: string; policy: DaemonPolicy; tools: string[] }): void {
83
87
  socket.serializeAttachment({
84
- role: "probing",
85
- connectedAt: values.connectedAt,
86
- lastSeenAt: values.connectedAt,
87
- probeId: values.probeId,
88
- instanceId: values.instanceId,
89
- policy: values.policy,
90
- tools: values.tools,
88
+ role: "probing", connectedAt: values.connectedAt, lastSeenAt: values.connectedAt,
89
+ probeId: values.probeId, instanceId: values.instanceId, policy: values.policy, tools: values.tools,
91
90
  } satisfies DaemonAttachment);
92
91
  }
93
92
 
@@ -112,10 +111,7 @@ export class DaemonSocketRegistry {
112
111
  const attachment = this.attachment(socket);
113
112
  if (!attachment) return;
114
113
  socket.serializeAttachment({
115
- role: "expired",
116
- connectedAt: attachment.connectedAt,
117
- lastSeenAt: attachment.lastSeenAt,
118
- instanceId: attachment.instanceId,
114
+ role: "expired", connectedAt: attachment.connectedAt, lastSeenAt: attachment.lastSeenAt, instanceId: attachment.instanceId,
119
115
  } satisfies DaemonAttachment);
120
116
  }
121
117
 
@@ -17,10 +17,11 @@ import { acceptsEventStream } from "./mcp-stream.ts";
17
17
  import { authorizeMcpRequest } from "./mcp-access.ts";
18
18
  import { handleMcpResumptionRequest } from "./mcp-resumption-http.ts";
19
19
  import {
20
- handleMcpStreamPollRequest, mcpStreamDescriptorResponse, mcpStreamProxyMode,
20
+ handleMcpStreamSubscribeRequest, mcpStreamDescriptorResponse, mcpStreamProxyMode,
21
21
  proxyMcpEventStream, sanitizeBridgeRequest,
22
22
  } from "./mcp-stream-proxy.ts";
23
23
  import { McpResumptionStore, McpStreamLimitError } from "./mcp-resumption.ts";
24
+ import { McpStreamChannel } from "./mcp-stream-channel.ts";
24
25
  import { buildServerInfoResult, persistImmediateStreamOutcome, startEventDrivenStreamCall } from "./mcp-stream-dispatch.ts";
25
26
  import { daemonToolTimeoutMs } from "./tool-timeout.ts";
26
27
  import { WorkerObservability } from "./observability.ts";
@@ -35,6 +36,9 @@ import {
35
36
  HttpError, applyCors, baseUrl, corsPreflight, json, methodNotAllowed,
36
37
  parseJsonRequest, workerErrorClass,
37
38
  } from "./http.ts";
39
+ import {
40
+ durableObjectQuotaResponse, isDurableObjectQuotaError, respondWithoutDurableObject,
41
+ } from "./worker-static-routes.ts";
38
42
  import {
39
43
  asObject, isJsonRpcRequest, isJsonRpcResponse, requiredString, rpcError, rpcResult,
40
44
  sessionInstructionText, textToolResult, validateProtocolVersionHeader, type JsonRpcRequest,
@@ -44,7 +48,7 @@ import {
44
48
  } from "./websocket-protocol.ts";
45
49
 
46
50
  const SERVER_NAME = String(serverMetadata.name);
47
- const SERVER_VERSION = "3.0.0-beta.16";
51
+ const SERVER_VERSION = "3.0.0-beta.17";
48
52
  const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
49
53
  const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
50
54
  const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
@@ -68,13 +72,15 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
68
72
  private readonly observability = new WorkerObservability();
69
73
  private readonly oauth: OAuthController;
70
74
  private readonly daemonRegistry: DaemonSocketRegistry;
75
+ private readonly streamChannel: McpStreamChannel;
71
76
  private readonly resumption: McpResumptionStore;
72
77
 
73
78
  constructor(ctx: DurableObjectState, env: BridgeEnv) {
74
79
  super(ctx, env);
75
80
  this.oauth = new OAuthController(ctx, env, SERVER_NAME, SERVER_VERSION);
76
81
  this.daemonRegistry = new DaemonSocketRegistry(ctx);
77
- this.resumption = new McpResumptionStore(ctx.storage);
82
+ this.streamChannel = new McpStreamChannel(ctx, this.observability);
83
+ this.resumption = new McpResumptionStore(ctx.storage, {}, (streamId, message) => this.streamChannel.publish(streamId, message));
78
84
  }
79
85
 
80
86
  async fetch(request: Request): Promise<Response> {
@@ -147,6 +153,10 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
147
153
 
148
154
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
149
155
  await this.pending.expireDue();
156
+ if (this.streamChannel.isSubscriber(ws)) {
157
+ this.streamChannel.rejectSubscriberMessage(ws);
158
+ return;
159
+ }
150
160
  const size = typeof message === "string" ? new TextEncoder().encode(message).byteLength : message.byteLength;
151
161
  if (size > MAX_DAEMON_MESSAGE_BYTES) {
152
162
  closeWebSocketQuietly(ws, 1009, "message too large");
@@ -321,10 +331,12 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
321
331
  }
322
332
 
323
333
  async webSocketClose(ws: WebSocket): Promise<void> {
334
+ if (this.streamChannel.isSubscriber(ws)) return;
324
335
  await this.cleanupDaemonSocket(ws, "daemon disconnected");
325
336
  }
326
337
 
327
338
  async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
339
+ if (this.streamChannel.isSubscriber(ws)) return;
328
340
  this.observability.event("warn", "daemon.websocket.error", { error_class: workerErrorClass(error) });
329
341
  await this.cleanupDaemonSocket(ws, "daemon transport error");
330
342
  }
@@ -343,8 +355,8 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
343
355
  }
344
356
 
345
357
  const proxyMode = mcpStreamProxyMode(request);
346
- const polled = await handleMcpStreamPollRequest(request, this.resumption);
347
- if (polled) return polled;
358
+ const subscribed = await handleMcpStreamSubscribeRequest(request, this.streamChannel, this.resumption);
359
+ if (subscribed) return subscribed;
348
360
 
349
361
  const access = await authorizeMcpRequest({
350
362
  request,
@@ -793,14 +805,28 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
793
805
 
794
806
  export default {
795
807
  async fetch(request: Request, env: BridgeEnv, ctx: ExecutionContext): Promise<Response> {
796
- const stub = env.BRIDGE.getByName("default");
797
- const streamed = await proxyMcpEventStream({
808
+ const extraOrigins = env.MBM_ALLOWED_ORIGINS ?? "";
809
+ const staticResponse = respondWithoutDurableObject(
798
810
  request,
799
- bridge: stub,
800
- extraOrigins: env.MBM_ALLOWED_ORIGINS ?? "",
801
- ctx,
802
- });
803
- return streamed ?? stub.fetch(sanitizeBridgeRequest(request));
811
+ { server: SERVER_NAME, version: SERVER_VERSION },
812
+ extraOrigins,
813
+ );
814
+ if (staticResponse) return staticResponse;
815
+
816
+ try {
817
+ const stub = env.BRIDGE.getByName("default");
818
+ const streamed = await proxyMcpEventStream({
819
+ request,
820
+ bridge: stub,
821
+ extraOrigins,
822
+ ctx,
823
+ });
824
+ return streamed ?? stub.fetch(sanitizeBridgeRequest(request));
825
+ } catch (error) {
826
+ // Free-tier DO request exhaustion must not look like an opaque Worker crash.
827
+ if (isDurableObjectQuotaError(error)) return durableObjectQuotaResponse(request, extraOrigins);
828
+ throw error;
829
+ }
804
830
  },
805
831
  } satisfies ExportedHandler<BridgeEnv>;
806
832
 
@@ -20,6 +20,7 @@ import { resumptionLimits, type McpResumptionOptions } from "./mcp-resumption-co
20
20
  export type { JsonRpcMessage } from "./mcp-resumption-records.ts";
21
21
  type ResumptionStorage = Pick<DurableObjectStorage, "get" | "put" | "delete" | "transaction">;
22
22
  type TransactionStorage = Pick<DurableObjectTransaction, "get" | "put" | "delete">;
23
+ type StreamReadyListener = (streamId: string, message: JsonRpcMessage) => void;
23
24
  export type StreamResumeResult =
24
25
  | { kind: "invalid" | "not_found" | "expired" }
25
26
  | { kind: "complete"; streamId: string }
@@ -40,12 +41,11 @@ export class McpResumptionStore {
40
41
  private readonly pendingRetentionMs: number;
41
42
  private readonly maximumStreams: number;
42
43
  private readonly maximumMessageBytes: number;
43
- constructor(
44
- storage: ResumptionStorage,
45
- options: McpResumptionOptions = {},
46
- ) {
44
+ private readonly onReady: StreamReadyListener;
45
+ constructor(storage: ResumptionStorage, options: McpResumptionOptions = {}, onReady: StreamReadyListener = () => {}) {
47
46
  this.storage = storage;
48
47
  this.now = options.now ?? Date.now;
48
+ this.onReady = onReady;
49
49
  const limits = resumptionLimits(options);
50
50
  this.retentionMs = limits.retentionMs;
51
51
  this.pendingRetentionMs = limits.pendingRetentionMs;
@@ -121,14 +121,12 @@ export class McpResumptionStore {
121
121
  throw error;
122
122
  } finally {
123
123
  this.active.delete(streamId);
124
+ // Persistent state remains authoritative if a disconnected subscriber misses this push.
125
+ try { this.onReady(streamId, message); } catch { /* resume/poll remains available */ }
124
126
  }
125
127
  }
126
128
 
127
- async pollMessage(streamId: string): Promise<
128
- | { kind: "pending" }
129
- | { kind: "not_found" }
130
- | { kind: "message"; message: JsonRpcMessage }
131
- > {
129
+ async pollMessage(streamId: string): Promise<{ kind: "pending" } | { kind: "not_found" } | { kind: "message"; message: JsonRpcMessage }> {
132
130
  if (!isStreamId(streamId)) return { kind: "not_found" };
133
131
  const transient = this.transientReady.get(streamId);
134
132
  if (transient) return { kind: "message", message: transient };
@@ -0,0 +1,125 @@
1
+ import { json } from "./http.ts";
2
+ import type { JsonRpcMessage, McpResumptionStore } from "./mcp-resumption.ts";
3
+ import { isStreamId } from "./mcp-resumption-records.ts";
4
+ import { closeWebSocketQuietly, trySendWebSocket } from "./websocket-protocol.ts";
5
+
6
+ const SUBSCRIBER_ROLE = "mcp_stream_subscriber";
7
+ const SUBSCRIBER_TAG_PREFIX = "mcp-stream:";
8
+
9
+ type StreamChannelContext = Pick<DurableObjectState, "acceptWebSocket" | "getWebSockets">;
10
+ type StreamChannelObservability = {
11
+ streamSubscriberOpened(replaced: number): void;
12
+ streamTerminalDelivered(recipients: number): void;
13
+ streamSubscriberProtocolError(): void;
14
+ };
15
+ type WebSocketPairFactory = () => [WebSocket, WebSocket];
16
+ type UpgradeResponseFactory = (client: WebSocket) => Response;
17
+ type StreamSubscriberAttachment = { role: typeof SUBSCRIBER_ROLE; streamId: string };
18
+
19
+ export class McpStreamChannel {
20
+ private readonly context: StreamChannelContext;
21
+ private readonly observability: StreamChannelObservability;
22
+ private readonly createPair: WebSocketPairFactory;
23
+ private readonly createUpgradeResponse: UpgradeResponseFactory;
24
+
25
+ constructor(
26
+ context: StreamChannelContext,
27
+ observability: StreamChannelObservability,
28
+ createPair: WebSocketPairFactory = defaultWebSocketPair,
29
+ createUpgradeResponse: UpgradeResponseFactory = defaultUpgradeResponse,
30
+ ) {
31
+ this.context = context;
32
+ this.observability = observability;
33
+ this.createPair = createPair;
34
+ this.createUpgradeResponse = createUpgradeResponse;
35
+ }
36
+
37
+ async subscribe(request: Request, streamId: string, resumption: McpResumptionStore): Promise<Response> {
38
+ if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
39
+ return new Response("Expected Upgrade: websocket", { status: 426 });
40
+ }
41
+
42
+ const initial = await resumption.pollMessage(streamId);
43
+ if (initial.kind === "message") return json(initial.message);
44
+ if (initial.kind === "not_found") return json({ error: "stream_not_found" }, 404);
45
+
46
+ const replaced = this.closeSubscribers(streamId, 1012, "replaced by resumed stream");
47
+ const [client, server] = this.createPair();
48
+ this.context.acceptWebSocket(server, [streamTag(streamId)]);
49
+ server.serializeAttachment({ role: SUBSCRIBER_ROLE, streamId } satisfies StreamSubscriberAttachment);
50
+ this.observability.streamSubscriberOpened(replaced);
51
+
52
+ try {
53
+ // Recheck after registration. Completion may race between the first storage
54
+ // read and acceptWebSocket(); either publish() or this read delivers it.
55
+ const current = await resumption.pollMessage(streamId);
56
+ if (current.kind === "message") this.sendTerminal(server, current.message);
57
+ else if (current.kind === "not_found") closeWebSocketQuietly(server, 1008, "stream unavailable");
58
+ } catch (error) {
59
+ closeWebSocketQuietly(server, 1011, "stream lookup failed");
60
+ throw error;
61
+ }
62
+
63
+ return this.createUpgradeResponse(client);
64
+ }
65
+
66
+ publish(streamId: string, message: JsonRpcMessage): void {
67
+ if (!isStreamId(streamId)) return;
68
+ const sockets = this.context.getWebSockets(streamTag(streamId));
69
+ let delivered = 0;
70
+ for (const socket of sockets) {
71
+ if (this.sendTerminal(socket, message)) delivered += 1;
72
+ }
73
+ this.observability.streamTerminalDelivered(delivered);
74
+ }
75
+
76
+ isSubscriber(socket: WebSocket): boolean {
77
+ const attachment = subscriberAttachment(socket);
78
+ return Boolean(attachment);
79
+ }
80
+
81
+ rejectSubscriberMessage(socket: WebSocket): void {
82
+ this.observability.streamSubscriberProtocolError();
83
+ closeWebSocketQuietly(socket, 1008, "stream subscribers are receive-only");
84
+ }
85
+
86
+ private sendTerminal(socket: WebSocket, message: JsonRpcMessage): boolean {
87
+ if (socket.readyState !== WebSocket.OPEN) return false;
88
+ const sent = trySendWebSocket(socket, message);
89
+ closeWebSocketQuietly(socket, sent ? 1000 : 1011, sent ? "stream complete" : "stream delivery failed");
90
+ return sent;
91
+ }
92
+
93
+ private closeSubscribers(streamId: string, code: number, reason: string): number {
94
+ let closed = 0;
95
+ for (const socket of this.context.getWebSockets(streamTag(streamId))) {
96
+ if (socket.readyState !== WebSocket.OPEN) continue;
97
+ closeWebSocketQuietly(socket, code, reason);
98
+ closed += 1;
99
+ }
100
+ return closed;
101
+ }
102
+ }
103
+
104
+ function subscriberAttachment(socket: WebSocket): StreamSubscriberAttachment | null {
105
+ const raw = socket.deserializeAttachment();
106
+ if (!raw || typeof raw !== "object") return null;
107
+ const candidate = raw as Partial<StreamSubscriberAttachment>;
108
+ const streamId = candidate.streamId;
109
+ if (candidate.role !== SUBSCRIBER_ROLE || typeof streamId !== "string" || !isStreamId(streamId)) return null;
110
+ return { role: SUBSCRIBER_ROLE, streamId };
111
+ }
112
+
113
+ function streamTag(streamId: string): string {
114
+ if (!isStreamId(streamId)) throw new Error("invalid MCP stream id");
115
+ return `${SUBSCRIBER_TAG_PREFIX}${streamId}`;
116
+ }
117
+
118
+ function defaultUpgradeResponse(client: WebSocket): Response {
119
+ return new Response(null, { status: 101, webSocket: client });
120
+ }
121
+
122
+ function defaultWebSocketPair(): [WebSocket, WebSocket] {
123
+ const pair = new WebSocketPair();
124
+ return Object.values(pair) as [WebSocket, WebSocket];
125
+ }
@@ -1,14 +1,16 @@
1
+ import relayContract from "../shared/relay-contract.json" with { type: "json" };
1
2
  import { applyCors, baseUrl, json } from "./http.ts";
2
3
  import { acceptsEventStream, resumeJsonRpcResponse, streamJsonRpcResponse } from "./mcp-stream.ts";
3
- import type { JsonRpcMessage, McpResumptionStore } from "./mcp-resumption.ts";
4
+ import type { McpStreamChannel } from "./mcp-stream-channel.ts";
5
+ import type { JsonRpcMessage } from "./mcp-resumption.ts";
4
6
 
5
7
  export const MCP_STREAM_PROXY_MODE_HEADER = "x-machine-bridge-internal-mcp-stream-mode";
6
8
  export const MCP_STREAM_PROXY_ID_HEADER = "x-machine-bridge-internal-mcp-stream-id";
7
9
  const MCP_STREAM_DESCRIPTOR_HEADER = "x-machine-bridge-mcp-stream-descriptor";
8
10
  const STREAM_ID_PATTERN = /^stream_[A-Za-z0-9_-]{43}$/;
9
- const DEFAULT_POLL_INTERVAL_MS = 250;
11
+ const MAX_TERMINAL_MESSAGE_BYTES = relayContract.maximumResumableMessageBytes + 1024;
10
12
 
11
- type StreamProxyMode = "prepare" | "poll" | "";
13
+ type StreamProxyMode = "prepare" | "subscribe" | "";
12
14
  type StreamDescriptorKind = "initial" | "resume" | "complete";
13
15
  type StreamDescriptor = { kind: StreamDescriptorKind; stream_id: string };
14
16
  type BridgeFetcher = { fetch(request: Request): Promise<Response> };
@@ -19,7 +21,6 @@ export async function proxyMcpEventStream(input: {
19
21
  bridge: BridgeFetcher;
20
22
  extraOrigins: string;
21
23
  ctx: StreamExecutionContext;
22
- pollIntervalMs?: number;
23
24
  }): Promise<Response | null> {
24
25
  const url = new URL(input.request.url);
25
26
  const eligible = input.request.method === "GET"
@@ -38,12 +39,7 @@ export async function proxyMcpEventStream(input: {
38
39
  );
39
40
  }
40
41
 
41
- const terminal = pollTerminalMessage(
42
- input.bridge,
43
- input.request.url,
44
- descriptor.stream_id,
45
- positiveInteger(input.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS),
46
- );
42
+ const terminal = subscribeTerminalMessage(input.bridge, input.request.url, descriptor.stream_id);
47
43
  input.ctx.waitUntil(terminal.then(() => undefined, () => undefined));
48
44
  const options = {
49
45
  streamId: descriptor.stream_id,
@@ -55,30 +51,28 @@ export async function proxyMcpEventStream(input: {
55
51
  return applyCors(response, input.request, baseUrl(input.request), input.extraOrigins);
56
52
  }
57
53
 
58
- export function sanitizeBridgeRequest(request: Request): Request {
59
- const headers = new Headers(request.headers);
60
- headers.delete(MCP_STREAM_PROXY_MODE_HEADER);
61
- headers.delete(MCP_STREAM_PROXY_ID_HEADER);
62
- return new Request(request, { headers });
63
- }
64
-
65
- export async function handleMcpStreamPollRequest(
54
+ export async function handleMcpStreamSubscribeRequest(
66
55
  request: Request,
67
- resumption: McpResumptionStore,
56
+ channel: McpStreamChannel,
57
+ resumption: Parameters<McpStreamChannel["subscribe"]>[2],
68
58
  ): Promise<Response | null> {
69
- if (mcpStreamProxyMode(request) !== "poll") return null;
59
+ if (mcpStreamProxyMode(request) !== "subscribe") return null;
70
60
  if (request.method !== "GET") return new Response(null, { status: 405, headers: { allow: "GET" } });
71
61
  const streamId = mcpStreamProxyId(request);
72
62
  if (!streamId) return json({ error: "invalid_internal_stream_id" }, 400);
73
- const outcome = await resumption.pollMessage(streamId);
74
- if (outcome.kind === "pending") return new Response(null, { status: 202, headers: { "cache-control": "no-store" } });
75
- if (outcome.kind === "not_found") return json({ error: "stream_not_found" }, 404);
76
- return json(outcome.message);
63
+ return await channel.subscribe(request, streamId, resumption);
64
+ }
65
+
66
+ export function sanitizeBridgeRequest(request: Request): Request {
67
+ const headers = new Headers(request.headers);
68
+ headers.delete(MCP_STREAM_PROXY_MODE_HEADER);
69
+ headers.delete(MCP_STREAM_PROXY_ID_HEADER);
70
+ return new Request(request, { headers });
77
71
  }
78
72
 
79
73
  export function mcpStreamProxyMode(request: Request): StreamProxyMode {
80
74
  const value = request.headers.get(MCP_STREAM_PROXY_MODE_HEADER)?.trim().toLowerCase() ?? "";
81
- return value === "prepare" || value === "poll" ? value : "";
75
+ return value === "prepare" || value === "subscribe" ? value : "";
82
76
  }
83
77
 
84
78
  export function mcpStreamProxyId(request: Request): string {
@@ -96,29 +90,72 @@ function withProxyHeaders(request: Request, mode: Exclude<StreamProxyMode, "">,
96
90
  const headers = new Headers(sanitized.headers);
97
91
  headers.set(MCP_STREAM_PROXY_MODE_HEADER, mode);
98
92
  if (streamId) headers.set(MCP_STREAM_PROXY_ID_HEADER, streamId);
99
- return new Request(sanitized, { headers });
93
+ if (mode === "subscribe") headers.set("Upgrade", "websocket");
94
+ return new Request(sanitized, { method: mode === "subscribe" ? "GET" : sanitized.method, headers });
100
95
  }
101
96
 
102
- async function pollTerminalMessage(
97
+ async function subscribeTerminalMessage(
103
98
  bridge: BridgeFetcher,
104
99
  requestUrl: string,
105
100
  streamId: string,
106
- intervalMs: number,
107
101
  ): Promise<JsonRpcMessage> {
108
- for (;;) {
109
- const internal = withProxyHeaders(new Request(requestUrl, { method: "GET" }), "poll", streamId);
110
- const response = await bridge.fetch(internal);
111
- if (response.status === 202) {
112
- await delay(intervalMs);
113
- continue;
114
- }
115
- if (!response.ok) throw new Error(`internal MCP stream poll failed (${response.status})`);
116
- const value: unknown = await response.json();
117
- if (!value || typeof value !== "object" || Array.isArray(value) || (value as { jsonrpc?: unknown }).jsonrpc !== "2.0") {
118
- throw new Error("internal MCP stream poll returned an invalid JSON-RPC message");
119
- }
120
- return value as JsonRpcMessage;
102
+ const internal = withProxyHeaders(new Request(requestUrl, { method: "GET" }), "subscribe", streamId);
103
+ const response = await bridge.fetch(internal);
104
+ if (response.status === 200) return await readJsonRpcResponse(response);
105
+ if (response.status !== 101 || !response.webSocket) {
106
+ throw new Error(`internal MCP stream subscription failed (${response.status})`);
121
107
  }
108
+ const socket = response.webSocket;
109
+ socket.accept();
110
+ return await terminalMessageFromSocket(socket);
111
+ }
112
+
113
+ function terminalMessageFromSocket(socket: WebSocket): Promise<JsonRpcMessage> {
114
+ return new Promise((resolve, reject) => {
115
+ let settled = false;
116
+ const fail = (message: string) => {
117
+ if (settled) return;
118
+ settled = true;
119
+ reject(new Error(message));
120
+ };
121
+ const succeed = (message: JsonRpcMessage) => {
122
+ if (settled) return;
123
+ settled = true;
124
+ resolve(message);
125
+ try { socket.close(1000, "terminal received"); } catch { /* Peer may have already completed the close handshake. */ }
126
+ };
127
+ socket.addEventListener("message", (event) => {
128
+ if (settled) return;
129
+ try {
130
+ const text = webSocketText(event.data);
131
+ if (new TextEncoder().encode(text).byteLength > MAX_TERMINAL_MESSAGE_BYTES) throw new Error("terminal message is too large");
132
+ succeed(jsonRpcMessage(JSON.parse(text)));
133
+ } catch (error) {
134
+ fail(error instanceof Error ? error.message : "invalid terminal message");
135
+ try { socket.close(1008, "invalid terminal message"); } catch { /* socket may already be closing */ }
136
+ }
137
+ });
138
+ socket.addEventListener("close", () => fail("internal MCP stream subscription closed before terminal result"));
139
+ socket.addEventListener("error", () => fail("internal MCP stream subscription failed"));
140
+ });
141
+ }
142
+
143
+ async function readJsonRpcResponse(response: Response): Promise<JsonRpcMessage> {
144
+ return jsonRpcMessage(await response.json());
145
+ }
146
+
147
+ function jsonRpcMessage(value: unknown): JsonRpcMessage {
148
+ if (!value || typeof value !== "object" || Array.isArray(value) || (value as { jsonrpc?: unknown }).jsonrpc !== "2.0") {
149
+ throw new Error("internal MCP stream subscription returned an invalid JSON-RPC message");
150
+ }
151
+ return value as JsonRpcMessage;
152
+ }
153
+
154
+ function webSocketText(value: unknown): string {
155
+ if (typeof value === "string") return value;
156
+ if (value instanceof ArrayBuffer) return new TextDecoder("utf-8", { fatal: true }).decode(value);
157
+ if (ArrayBuffer.isView(value)) return new TextDecoder("utf-8", { fatal: true }).decode(value);
158
+ throw new Error("terminal message must be text or binary UTF-8");
122
159
  }
123
160
 
124
161
  async function readDescriptor(response: Response): Promise<StreamDescriptor> {
@@ -137,12 +174,3 @@ function stripInternalResponseHeaders(response: Response): Response {
137
174
  headers.delete(MCP_STREAM_DESCRIPTOR_HEADER);
138
175
  return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
139
176
  }
140
-
141
- function positiveInteger(value: unknown, fallback: number): number {
142
- const parsed = Number(value);
143
- return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
144
- }
145
-
146
- function delay(ms: number): Promise<void> {
147
- return new Promise((resolve) => setTimeout(resolve, ms));
148
- }
@@ -9,6 +9,7 @@ export class WorkerObservability {
9
9
  private readonly requests = { total: 0, successful: 0, client_error: 0, server_error: 0 };
10
10
  private readonly calls = { started: 0, completed: 0, failed: 0, cancelled: 0, timed_out: 0, unmatched_results: 0 };
11
11
  private readonly sockets = { candidates: 0, authenticated: 0, ready: 0, disconnected: 0, protocol_errors: 0 };
12
+ private readonly streamTransport = { subscribers_opened: 0, subscribers_replaced: 0, terminal_pushes: 0, terminal_recipients: 0, protocol_errors: 0 };
12
13
  private readonly errors = new Map<string, number>();
13
14
  private readonly tools = new Map<string, { started: number; completed: number; failed: number; active: number }>();
14
15
 
@@ -52,12 +53,28 @@ export class WorkerObservability {
52
53
  this.incrementError(code || "protocol_error");
53
54
  }
54
55
 
56
+ streamSubscriberOpened(replaced: number): void {
57
+ this.streamTransport.subscribers_opened += 1;
58
+ this.streamTransport.subscribers_replaced += Math.max(0, Math.floor(replaced));
59
+ }
60
+
61
+ streamTerminalDelivered(recipients: number): void {
62
+ this.streamTransport.terminal_pushes += 1;
63
+ this.streamTransport.terminal_recipients += Math.max(0, Math.floor(recipients));
64
+ }
65
+
66
+ streamSubscriberProtocolError(): void {
67
+ this.streamTransport.protocol_errors += 1;
68
+ this.incrementError("stream_subscriber_protocol_error");
69
+ }
70
+
55
71
  snapshot(): Record<string, unknown> {
56
72
  return {
57
73
  uptime_ms: Math.max(0, performance.now() - this.startedAt),
58
74
  requests: { ...this.requests },
59
75
  calls: { ...this.calls },
60
76
  sockets: { ...this.sockets },
77
+ stream_transport: { ...this.streamTransport },
61
78
  errors: Object.fromEntries([...this.errors.entries()].sort(([left], [right]) => left.localeCompare(right))),
62
79
  tools: Object.fromEntries([...this.tools.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([name, metric]) => [name, { ...metric }])),
63
80
  };
@@ -0,0 +1,60 @@
1
+ import {
2
+ applyCors,
3
+ baseUrl,
4
+ corsPreflight,
5
+ json,
6
+ methodNotAllowed,
7
+ } from "./http.ts";
8
+
9
+ export function respondWithoutDurableObject(
10
+ request: Request,
11
+ identity: { server: string; version: string },
12
+ extraOrigins = "",
13
+ ): Response | null {
14
+ const url = new URL(request.url);
15
+ const base = baseUrl(request);
16
+ const path = url.pathname;
17
+ const serverName = identity.server;
18
+ const serverVersion = identity.version;
19
+
20
+ if (request.method === "OPTIONS" && request.headers.has("Origin")) {
21
+ // CORS preflight does not need Durable Object state and must not consume DO quota.
22
+ return corsPreflight(request, base, extraOrigins);
23
+ }
24
+
25
+ if (path === "/healthz") {
26
+ if (request.method !== "GET") return applyCors(methodNotAllowed("GET"), request, base, extraOrigins);
27
+ return applyCors(json({ ok: true, server: serverName, version: serverVersion }), request, base, extraOrigins);
28
+ }
29
+
30
+ if (path === "/") {
31
+ if (request.method !== "GET") return applyCors(methodNotAllowed("GET"), request, base, extraOrigins);
32
+ return applyCors(
33
+ json({ ok: true, server: serverName, version: serverVersion, mcp: `${base}/mcp` }),
34
+ request,
35
+ base,
36
+ extraOrigins,
37
+ );
38
+ }
39
+
40
+ return null;
41
+ }
42
+
43
+ export function isDurableObjectQuotaError(error: unknown): boolean {
44
+ const message = error instanceof Error ? error.message : String(error ?? "");
45
+ return /Exceeded allowed volume of requests in Durable Objects free tier/i.test(message)
46
+ || /Durable Objects free tier/i.test(message);
47
+ }
48
+
49
+ export function durableObjectQuotaResponse(request: Request, extraOrigins = ""): Response {
50
+ return applyCors(
51
+ json({
52
+ error: "durable_object_quota_exceeded",
53
+ message: "Durable Objects free-tier request volume is exhausted until the daily UTC reset.",
54
+ retryable: true,
55
+ }, 503),
56
+ request,
57
+ baseUrl(request),
58
+ extraOrigins,
59
+ );
60
+ }