diff --git a/docs/rfds/streamable-http-websocket-transport.mdx b/docs/rfds/streamable-http-websocket-transport.mdx index 8a79c221a..5ceb0fcfc 100644 --- a/docs/rfds/streamable-http-websocket-transport.mdx +++ b/docs/rfds/streamable-http-websocket-transport.mdx @@ -9,9 +9,9 @@ title: "Streamable HTTP & WebSocket Transport" > What are you proposing to change? -ACP needs a standard remote transport. We propose a **single long-lived GET stream** for all server→client messages, with **POST** for client→server messages, and **WebSocket upgrade** as an alternative on the same endpoint. A single `/acp` endpoint supports two connectivity profiles: +ACP needs a standard remote transport. We propose **long-lived GET streams** for server→client messages (one connection-scoped plus one per session), with **POST** for client→server messages, and **WebSocket upgrade** as an alternative on the same endpoint. A single `/acp` endpoint supports two connectivity profiles: -- **Streamable HTTP (POST/GET/DELETE)** — Single long-lived SSE stream per connection for all server→client messages (responses and notifications). POST requests return immediately (202 Accepted, except `initialize`). Requires HTTP/2. +- **Streamable HTTP (POST/GET/DELETE)** — Long-lived SSE streams per connection: one connection-scoped stream for connection-level server→client messages, plus one session-scoped stream per session for session-level messages. POST requests return immediately (202 Accepted, except `initialize`). Requires HTTP/2. - **WebSocket upgrade (GET with `Upgrade: websocket`)** — persistent, full-duplex, low-latency bidirectional messaging. Clients that support remote ACP over HTTP MUST support both Streamable HTTP and WebSocket. This allows servers to support only WebSocket if they choose, simplifying deployment. @@ -32,11 +32,11 @@ ACP only has stdio. There is no standard remote transport, which causes fragment ACP adopts a streamable HTTP transport with three key characteristics: -1. **Single long-lived GET stream per connection** — All server→client messages (responses to requests and unsolicited notifications) are delivered via a single SSE stream opened with GET. This includes responses to client requests (correlated by JSON-RPC `id`), server-initiated notifications (no `id`), and server-to-client requests like `request_permission` (with `id`, client responds via POST). The GET stream is scoped to `Acp-Connection-Id` and delivers messages for all sessions within that connection. Session identity is carried in the JSON-RPC message body (`sessionId` field). +1. **Long-lived GET streams (one connection-scoped, one per session)** — All server→client messages (responses to requests and unsolicited notifications) are delivered via SSE streams opened with GET. The **connection-scoped stream** (scoped to `Acp-Connection-Id`) carries connection-level messages: responses to `session/new` and `session/load` (which the client cannot receive on a session-scoped stream because it does not yet have a `sessionId`), and any server-initiated messages not tied to a specific session. The **session-scoped stream** (scoped to `Acp-Connection-Id` + `Acp-Session-Id`) carries all messages for a single session: session update notifications, server-to-client requests like `request_permission`, and responses to session-scoped POSTs like `session/prompt` and `session/cancel`. Responses are correlated to the POST that originated them by JSON-RPC `id`. -2. **POST requests return immediately (except initialize)** — Client→server messages are sent via POST. Most POST requests return `202 Accepted` immediately with an empty body. The actual response comes later on the GET stream, correlated by JSON-RPC `id`. The `initialize` request is special: it returns `200 OK` with a JSON response body containing capabilities and the `Acp-Connection-Id`. The `Acp-Connection-Id` is also included in the response header. +2. **POST requests return immediately (except initialize)** — Client→server messages are sent via POST. Most POST requests return `202 Accepted` immediately with an empty body. The actual response comes later on the appropriate GET stream, correlated by JSON-RPC `id`. The `initialize` request is special: it returns `200 OK` with a JSON response body containing capabilities and the `Acp-Connection-Id`. The `Acp-Connection-Id` is also included in the response header. -3. **Requires HTTP/2** — Streamable HTTP transport MUST use HTTP/2. This provides multiplexing for concurrent POST requests while maintaining a single long-lived GET stream, and improves efficiency for high-frequency message exchanges. +3. **Requires HTTP/2** — Streamable HTTP transport MUST use HTTP/2. This provides multiplexing for concurrent POST requests while maintaining long-lived GET streams (one connection-scoped plus one per session), and improves efficiency for high-frequency message exchanges. ### 4. Adds WebSocket as a first-class upgrade on the same endpoint @@ -50,12 +50,12 @@ Clients MUST accept, store, and return cookies set by the server on all HTTP-bas ### 6. Defines a unified routing model -| Method | Upgrade Header? | Behavior | -| -------- | -------------------- | ----------------------------------------------------------------------------------------------------------- | -| `POST` | — | Send JSON-RPC message. `initialize` returns 200 with JSON body. All others return 202 Accepted immediately. | -| `GET` | No | Open connection-scoped SSE stream for all server→client messages. Requires `Acp-Connection-Id`. | -| `GET` | `Upgrade: websocket` | Upgrade to WebSocket for full-duplex messaging | -| `DELETE` | — | Terminate the connection | +| Method | Upgrade Header? | Behavior | +| -------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `POST` | — | Send JSON-RPC message. `initialize` returns 200 with JSON body. All others return 202 Accepted immediately. | +| `GET` | No | Open SSE stream. `Acp-Connection-Id` alone → connection-scoped stream. `Acp-Connection-Id` + `Acp-Session-Id` → session-scoped stream. | +| `GET` | `Upgrade: websocket` | Upgrade to WebSocket for full-duplex messaging | +| `DELETE` | — | Terminate the connection | ### 7. Preserves the full ACP lifecycle @@ -96,9 +96,9 @@ The `initialize` → `initialized` → messages → close lifecycle is identical ACP over Streamable HTTP uses two HTTP headers for connection and session identity, plus JSON-RPC message fields: -- **`Acp-Connection-Id`** (HTTP header) — Transport-level identifier returned by the server in the `initialize` response. Required on all HTTP requests after `initialize` and on the GET stream. Binds requests to an initialized connection and its negotiated capabilities. -- **`Acp-Session-Id`** (HTTP header) — Session-level identifier returned in the `session/new` response body. Required on all session-scoped POST requests (`session/prompt`, `session/cancel`, permission responses, etc.). Enables routing and debugging. -- **`sessionId`** (JSON-RPC field) — Session-level identifier also included in JSON-RPC `params` for session-scoped methods and in responses on the GET stream. A single connection may host multiple sessions, each with its own `sessionId`. +- **`Acp-Connection-Id`** (HTTP header) — Transport-level identifier returned by the server in the `initialize` response. Required on all HTTP requests after `initialize` and on every GET stream (both connection-scoped and session-scoped). Binds requests to an initialized connection and its negotiated capabilities. +- **`Acp-Session-Id`** (HTTP header) — Session-level identifier returned in the `session/new` response body. Required on all session-scoped POST requests (`session/prompt`, `session/cancel`, permission responses, etc.) and on the session-scoped GET stream. +- **`sessionId`** (JSON-RPC field) — Session-level identifier also included in JSON-RPC `params` for session-scoped methods and in responses on the GET streams. A single connection may host multiple sessions, each with its own `sessionId` and its own session-scoped GET stream. ### Streamable HTTP Message Flow @@ -114,11 +114,11 @@ Client Server │ Acp-Connection-Id: │ Response includes Acp-Connection-Id header │ Content-Type: application/json │ │ │ - │ ═══ Open GET Stream ═══ │ + │ ═══ Open Connection-Scoped GET ═══│ │ │ - │─── GET /acp ──────────────────────>│ Open long-lived SSE stream - │ Acp-Connection-Id: │ for all server→client messages - │ Accept: text/event-stream │ + │─── GET /acp ──────────────────────>│ Open long-lived connection-scoped SSE stream + │ Acp-Connection-Id: │ for connection-level server→client messages + │ Accept: text/event-stream │ (no Acp-Session-Id header) │ ┌─────────────────────│ (SSE stream open) │ │ │ │ │ │ @@ -130,9 +130,17 @@ Client Server │<────── 202 Accepted ───────────────│ (returns immediately) │ │ │ │<─────────────│─ SSE event ─────────│ { id: 2, result: { sessionId: "sess_abc123" } } - │ │ │ (response comes on GET stream) + │ │ │ (response on connection-scoped stream) + │ │ │ + │ ═══ Open Session-Scoped GET ═══ │ + │ │ + │─── GET /acp ──────────────────────>│ Open long-lived session-scoped SSE stream + │ Acp-Connection-Id: │ for sess_abc123 + │ Acp-Session-Id: sess_abc123 │ + │ Accept: text/event-stream │ + │ ┌─────────────────────│ (SSE stream open) │ │ │ - │ ═══ Prompt Flow ═══ │ + │ ═══ Prompt Flow ═══ │ (all events below arrive on the session-scoped stream) │ │ │─── POST /acp ─────────────────────>│ { method: "session/prompt", id: 3, │ Acp-Connection-Id: │ params: { sessionId: "sess_abc123", prompt } } @@ -198,22 +206,27 @@ Client Server │<────── 200 OK ─────────────────────│ { id: 1, result: { capabilities, connectionId } } │ Acp-Connection-Id: │ │ │ - │─── GET /acp ──────────────────────>│ Open new GET stream + │─── GET /acp ──────────────────────>│ Open new connection-scoped GET stream │ Acp-Connection-Id: │ │ ┌─────────────────────│ (SSE stream open) │ │ │ + │─── GET /acp ──────────────────────>│ Open session-scoped GET stream for sess_abc123 + │ Acp-Connection-Id: │ + │ Acp-Session-Id: sess_abc123 │ + │ ┌─────────────────────│ (SSE stream open) + │ │ │ │─── POST /acp ─────────────────────>│ { method: "session/load", id: 2, │ Acp-Connection-Id: │ params: { sessionId: "sess_abc123", cwd } } │ Acp-Session-Id: sess_abc123 │ │ │ │<────── 202 Accepted ───────────────│ │ │ │ - │<─────────────│─ SSE event ─────────│ notification: UserMessageChunk (sessionId: "sess_abc123") - │<─────────────│─ SSE event ─────────│ notification: AgentMessageChunk (sessionId: "sess_abc123") - │<─────────────│─ SSE event ─────────│ notification: ToolCall (sessionId: "sess_abc123") - │<─────────────│─ SSE event ─────────│ notification: ToolCallUpdate (sessionId: "sess_abc123") + │<─────────────│─ SSE event ─────────│ notification: UserMessageChunk (on session-scoped stream) + │<─────────────│─ SSE event ─────────│ notification: AgentMessageChunk (on session-scoped stream) + │<─────────────│─ SSE event ─────────│ notification: ToolCall (on session-scoped stream) + │<─────────────│─ SSE event ─────────│ notification: ToolCallUpdate (on session-scoped stream) │<─────────────│─ SSE event ─────────│ { id: 2, result: { sessionId: "sess_abc123" } } - │ │ │ (response comes on GET stream) + │ │ │ (response on connection-scoped stream) │ │ │ │ ═══ Connection Termination ═══ │ │ │ @@ -228,6 +241,7 @@ Client Server - POST `Content-Type` **MUST** be `application/json` (415 otherwise). - GET `Accept` **MUST** include `text/event-stream` (406 otherwise). - POST requests for session-scoped operations **MUST** include both `Acp-Connection-Id` and `Acp-Session-Id` headers. +- GET requests without `Upgrade: websocket` **MUST** include `Acp-Connection-Id`. If `Acp-Session-Id` is also present, the stream is session-scoped; otherwise it is connection-scoped. An unknown `Acp-Session-Id` for the given connection returns 404. - Batch JSON-RPC requests return 501. - HTTP/2 is **REQUIRED** for Streamable HTTP transport. @@ -260,7 +274,9 @@ GET /acp └── No → SSE stream handler ├── Missing Acp-Connection-Id? → 400 Bad Request ├── Unknown Acp-Connection-Id? → 404 Not Found - └── Valid Acp-Connection-Id → Open connection-scoped SSE stream + ├── Has Acp-Session-Id unknown for this connection? → 404 Not Found + ├── Has Acp-Session-Id → Open session-scoped SSE stream + └── No Acp-Session-Id → Open connection-scoped SSE stream POST /acp ├── Initialize request (no Acp-Connection-Id)? → Create connection, return 200 with JSON @@ -281,7 +297,7 @@ Connection { connection_id: String, // Acp-Connection-Id capabilities: NegotiatedCapabilities, sessions: HashMap, // keyed by sessionId (JSON-RPC field) - get_stream: Option, // Single GET stream for this connection + get_stream: Option, // Connection-scoped GET stream to_agent_tx: mpsc::Sender, from_agent_rx: Arc>>, handle: JoinHandle<()>, @@ -289,11 +305,12 @@ Connection { Session { session_id: String, // sessionId (JSON-RPC field) + get_stream: Option, // Session-scoped GET stream // session-specific state } ``` -The agent task is spawned once per connection. A single GET SSE stream delivers all server→client messages for that connection, regardless of which session they belong to. Sessions are identified by the `sessionId` field in JSON-RPC messages. The transport layer adapts channels to the wire format (SSE events for HTTP, text frames for WebSocket). +The agent task is spawned once per connection. Server→client messages are routed to either the connection-scoped GET stream or the appropriate session-scoped GET stream based on whether the message is tied to a specific session. Sessions are identified by the `sessionId` field in JSON-RPC messages. The transport layer adapts channels to the wire format (SSE events for HTTP, text frames for WebSocket). ### Comparing to MCP Streamable HTTP @@ -302,10 +319,10 @@ The agent task is spawned once per connection. A single GET SSE stream delivers | POST for all client→server messages | ✅ | Compliant | | Accept header validation (406) | ✅ | Compliant | | Notifications/responses return 202 | ✅ (except `initialize` returns 200) | Mostly compliant | -| Requests return SSE stream | ❌ (single long-lived GET stream instead) | Documented deviation | +| Requests return SSE stream | ❌ (long-lived GET streams instead) | Documented deviation | | Session ID on initialize response | ✅ (`Acp-Connection-Id`) | Compliant (renamed) | | Session ID required on subsequent requests | ✅ (`Acp-Connection-Id` + `Acp-Session-Id`) | Compliant (extended) | -| GET opens SSE stream | ✅ (single connection-scoped stream) | Compliant | +| GET opens SSE stream | ✅ (connection-scoped + session-scoped) | Compliant (extended) | | DELETE terminates session | ✅ (terminates connection) | Compliant | | 404 for unknown sessions | ✅ (unknown connection IDs) | Compliant | | Batch requests | ❌ (returns 501) | Documented deviation | @@ -314,10 +331,10 @@ The agent task is spawned once per connection. A single GET SSE stream delivers ### Deviations from MCP Streamable HTTP -1. **Single long-lived GET stream**: MCP opens a new SSE stream for each request response. ACP uses a single long-lived GET stream per connection for all server→client messages. POST requests (except `initialize`) return 202 Accepted immediately, and responses arrive on the GET stream correlated by JSON-RPC `id`. +1. **Long-lived GET streams (connection-scoped + per-session)**: MCP opens a new SSE stream for each request response. ACP uses long-lived GET streams per connection — one connection-scoped stream plus one session-scoped stream per session. POST requests (except `initialize`) return 202 Accepted immediately, and responses arrive on the appropriate GET stream correlated by JSON-RPC `id`. 2. **Initialize returns JSON directly**: MCP's `initialize` returns an SSE stream. ACP's `initialize` returns `200 OK` with a JSON response body containing capabilities and `connectionId`. The `Acp-Connection-Id` is also included in the response header. 3. **HTTP/2 required**: ACP requires HTTP/2 for multiplexing concurrent POST requests alongside the long-lived GET stream. -4. **Two-header model**: ACP uses both `Acp-Connection-Id` (for connection identity) and `Acp-Session-Id` (for session identity on POST requests). MCP only uses `Mcp-Session-Id`. This allows ACP to distinguish connection-level state from session-level operations while supporting multiple concurrent sessions on one connection. +4. **Two-header model**: ACP uses both `Acp-Connection-Id` (for connection identity) and `Acp-Session-Id` (for session identity on POST requests and on the session-scoped GET stream). MCP only uses `Mcp-Session-Id`. This allows ACP to distinguish connection-level state from session-level operations while supporting multiple concurrent sessions on one connection. 5. **WebSocket extension**: MCP doesn't define WebSocket. ACP adds it as a required client capability. Clients MUST support WebSocket, and servers MAY choose to only support WebSocket connections. 6. **Cookie support required**: Clients MUST handle cookies on HTTP transports for the duration of the connection, enabling sticky sessions and per-connection server state. 7. **No batch requests**: Returns 501. May be added later. @@ -336,11 +353,11 @@ The agent task is spawned once per connection. A single GET SSE stream delivers ### Why not just use MCP Streamable HTTP as-is? -MCP opens a new SSE stream for each request response, which creates many long-lived connections and complicates load balancing. ACP uses a single long-lived GET stream per connection for all server→client messages, dramatically reducing connection count and simplifying sticky session routing. This is better suited for ACP's bidirectional, multi-session nature. +MCP opens a new SSE stream for each request response, which creates many short-lived connections and complicates load balancing. ACP uses long-lived GET streams per connection (one connection-scoped plus one per session), dramatically reducing connection count and simplifying sticky session routing. This is better suited for ACP's bidirectional, multi-session nature. ### How are sessions identified? -ACP uses `Acp-Connection-Id` in HTTP headers to identify the connection, and `sessionId` in JSON-RPC message bodies to identify sessions. A single connection may host multiple sessions. The single GET stream delivers messages for all sessions, and clients demux by the `sessionId` field in each message. +ACP uses `Acp-Connection-Id` in HTTP headers to identify the connection, and `Acp-Session-Id` (plus the `sessionId` JSON-RPC field) to identify sessions. A single connection may host multiple sessions. The connection-scoped GET stream delivers connection-level messages; each session-scoped GET stream delivers messages for exactly one session. ### Why add WebSocket support? @@ -352,14 +369,14 @@ By inspecting the `Upgrade: websocket` header. This is standard HTTP behavior. ### Can a client have multiple sessions on one connection? -Yes. A client may call `session/new` multiple times within a single `Acp-Connection-Id`. Each returns a distinct `sessionId` in the response body. All messages for all sessions are delivered on the single GET stream. The client demuxes messages by the `sessionId` field in each JSON-RPC message. +Yes. A client may call `session/new` multiple times within a single `Acp-Connection-Id`. Each returns a distinct `sessionId` in the response body (delivered on the connection-scoped GET stream). For each session, the client opens a separate session-scoped GET stream using `Acp-Connection-Id` + `Acp-Session-Id`. ### What alternative approaches did you consider, and why did you settle on this one? - **Per-request SSE streams (like MCP)**: Rejected — creates too many long-lived connections, complicates load balancing, and wastes resources. - **Separate endpoints** (`/acp/http`, `/acp/ws`): Rejected — single endpoint is simpler; WebSocket upgrade is natural HTTP. - **WebSocket only**: Rejected — doesn't work through all proxies. -- **Session-scoped GET streams**: Rejected — still creates multiple long-lived connections per client. Connection-scoped stream with JSON-RPC demuxing is simpler. +- **Single connection-scoped GET stream with JSON-RPC demuxing**: Rejected — forces both server and client to parse JSON-RPC bodies to route by session, couples all sessions' backpressure together, and makes per-session resume/reconnect awkward. Splitting into a connection-scoped stream plus per-session streams keeps all session-level routing on HTTP headers. ### How does this interact with authentication? @@ -371,7 +388,7 @@ Clients SHOULD include it on all requests after initialization. Not yet implemen ### Why require HTTP/2? -HTTP/2 provides multiplexing, allowing many concurrent POST requests alongside the long-lived GET stream on a single TCP connection. This is essential for efficient operation with the single-stream model. HTTP/1.1 would require separate TCP connections for each concurrent POST, defeating the efficiency gains. +HTTP/2 provides multiplexing, allowing many concurrent POST requests alongside the long-lived GET streams (one connection-scoped plus one per active session) on a single TCP connection. This is essential for efficient operation with the long-lived-stream model. HTTP/1.1 would require separate TCP connections for each concurrent POST and each GET stream, defeating the efficiency gains. ## Revision history @@ -379,3 +396,4 @@ HTTP/2 provides multiplexing, allowing many concurrent POST requests alongside t - **2026-04-01**: Introduced a two-header identity model: `Acp-Connection-Id` (returned at `initialize`, binds to the connection) and `Acp-Session-Id` (returned at `session/new`, scopes to a session). This addresses feedback that the original single `Acp-Session-Id` conflated transport binding with ACP session identity, and enables session-scoped GET listener streams for targeted server-to-client event delivery. Removed connection-scoped GET streams — all GET SSE listeners now require both `Acp-Connection-Id` and `Acp-Session-Id`. - **2026-04-15**: Minor edits - **2026-04-23**: Major revision to single long-lived GET stream model. Changed from per-request SSE streams to a single connection-scoped GET stream for all server→client messages. POST requests (except `initialize`) now return 202 Accepted immediately. `initialize` returns 200 OK with JSON response body. Required HTTP/2 for multiplexing. This change makes the HTTP usage more similar to WebSocket and supports better the bidirectional nature of ACP. +- **2026-05-04**: Split the single GET stream into two: a connection-scoped stream (GET with `Acp-Connection-Id`) for connection-level messages such as responses to `session/new` and `session/load`, and session-scoped streams (GET with `Acp-Connection-Id` + `Acp-Session-Id`) for session updates, server-to-client requests like `request_permission`, and responses to session-scoped POSTs. Routing happens on HTTP headers rather than JSON-RPC body inspection; per-session streams have independent lifetimes.