From a75a63a0a69cc781ba27266e743be6a68f4004e4 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Tue, 10 Mar 2026 09:56:08 -0400 Subject: [PATCH 1/2] Add RFD: Streamable HTTP & WebSocket Transport Co-authored-by: Jasper Hugo --- .../streamable-http-websocket-transport.mdx | 317 ++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 docs/rfds/streamable-http-websocket-transport.mdx diff --git a/docs/rfds/streamable-http-websocket-transport.mdx b/docs/rfds/streamable-http-websocket-transport.mdx new file mode 100644 index 00000000..38933b4c --- /dev/null +++ b/docs/rfds/streamable-http-websocket-transport.mdx @@ -0,0 +1,317 @@ +--- +title: "Streamable HTTP & WebSocket Transport" +--- + +Author(s): +* Alex Hancock alexhancock@block.xyz (https://github.com/alexhancock) +* Jasper Hugo jhugo@block.xyz (https://github.com/jh-block) + +## Elevator pitch + +> What are you proposing to change? + +ACP needs a standard remote transport. We propose adopting **MCP Streamable HTTP** (2025-11-25) with ACP-specific headers, and extending it with a **WebSocket upgrade** on the same endpoint. A single `/acp` endpoint supports two connectivity profiles: + +- **Streamable HTTP (POST/GET/DELETE)** — stateless-friendly, SSE-based streaming, aligned with MCP Streamable HTTP. +- **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. + +Both profiles share the same JSON-RPC message format and ACP lifecycle as the existing **stdio** local subprocess transport. + +## Status quo + +> How do things work today and what problems does this cause? Why would we change things? + +ACP only has stdio (inherited from MCP). There is no standard remote transport, which causes: + +1. **Fragmentation** — implementers invent their own HTTP layers, leading to incompatible SDKs and deployments. +2. **Missed alignment** — MCP Streamable HTTP is well-designed; ACP should adopt it rather than diverge. + +## What we propose to do about it + +> What are you proposing to improve the situation? + +### 1. Adopts MCP Streamable HTTP semantics with ACP-specific headers + +Follows the MCP 2025-11-25 Streamable HTTP spec with these adaptations: + +- Session header: `Acp-Session-Id` (not `MCP-Session-Id`) +- Protocol version header: `Acp-Protocol-Version` (not `MCP-Protocol-Version`) +- Endpoint path: conventionally `/acp` + +### 2. Adds WebSocket as a first-class upgrade on the same endpoint + +A GET with `Upgrade: websocket` upgrades to a persistent bidirectional channel — same endpoint, same session model. + +This is important for ACP, as its more bidirectional in its nature as a protocol + +### 3. Requires cookie support on HTTP transports + +Clients MUST accept, store, and return cookies set by the server on all HTTP-based transports (Streamable HTTP and WebSocket). Cookies MUST be sent on subsequent requests to the server for the duration of the session. Clients MAY discard all cookies when a session is complete. This allows servers to rely on cookies for session affinity (e.g., sticky sessions behind a load balancer) and other small amounts of per-session state. + +### 4. Defines a unified routing model + +| Method | Upgrade Header? | Behavior | +|--------|-----------------|----------| +| `POST` | — | Send JSON-RPC request/notification/response (Streamable HTTP) | +| `GET` | No | Open SSE stream for server-initiated messages (Streamable HTTP) | +| `GET` | `Upgrade: websocket` | Upgrade to WebSocket for full-duplex messaging | +| `DELETE` | — | Terminate the session | + +### 5. Preserves the full ACP lifecycle + +The `initialize` → `initialized` → messages → close lifecycle is identical regardless of transport. Session state is keyed by `Acp-Session-Id` and is transport-agnostic. + +## Shiny future + +> How will things play out once this feature exists? + +- **SDK implementers** get a clear, testable transport spec — Rust, TypeScript, and Python SDKs can all interoperate. +- **Desktop clients** use WebSocket for low-latency streaming; all clients support it as a baseline. +- **Cloud deployments** expose agents behind standard HTTP load balancers using the stateless-friendly HTTP mode, with cookie-based sticky sessions guaranteed by client support. +- **MCP compatibility** is maintained — the HTTP transport is a superset of MCP Streamable HTTP. +- **Proxy chains** can route ACP traffic over HTTP for multi-hop agent topologies. + +## Implementation details and plan + +> Tell me more about your implementation. What is your detailed implementation plan? + +### Transport Architecture + +``` + ┌─────────────────────────────────┐ + │ /acp endpoint │ + └──────┬──────────┬───────────────┘ + │ │ + ┌───────────▼──┐ ┌────▼──────────────┐ + │ HTTP State │ │ WebSocket State │ + │ (sessions) │ │ (connections) │ + └───────┬──────┘ └────┬──────────────┘ + │ │ + ┌───────▼──────────────▼───────────────┐ + │ ACP Agent (JSON-RPC handler) │ + │ serve(agent, read, write) │ + └─────────────────────────────────────┘ +``` + +### Streamable HTTP Message Flow + +``` +Client Server + │ │ + │ ═══ Session Initialization ═══ │ + │ │ + │─── POST /acp ─────────────────────>│ { method: "initialize", id: 1 } + │ Accept: application/json, │ (no Acp-Session-Id header) + │ text/event-stream │ + │ ┌─────────────────────│ Server creates session, opens SSE stream + │ │ (SSE stream open) │ + │<─────────────│─ SSE event ─────────│ { id: 1, result: { capabilities } } + │ │ │ Response includes Acp-Session-Id header + │ ▼ │ + │ │ + │ ═══ Prompt Flow ═══ │ + │ │ + │─── POST /acp ─────────────────────>│ { method: "session/new", id: 2, + │ Acp-Session-Id: │ params: { cwd, mcp_servers } } + │ ┌─────────────────────│ Opens new SSE stream for response + │<─────────────│─ SSE event ─────────│ { id: 2, result: { session_id: } } + │ ▼ │ + │ │ + │─── POST /acp ─────────────────────>│ { method: "session/prompt", id: 3, + │ Acp-Session-Id: │ params: { session_id, prompt } } + │ ┌─────────────────────│ Opens new SSE stream for response + │<─────────────│─ SSE event ─────────│ notification: AgentMessageChunk + │<─────────────│─ SSE event ─────────│ notification: AgentThoughtChunk (if reasoning) + │<─────────────│─ SSE event ─────────│ notification: ToolCall (status: pending) + │<─────────────│─ SSE event ─────────│ notification: ToolCallUpdate (status: completed) + │<─────────────│─ SSE event ─────────│ notification: AgentMessageChunk + │<─────────────│─ SSE event ─────────│ { id: 3, result: { stop_reason: "end_turn" } } + │ ▼ │ + │ │ + │ ═══ Permission Flow ═══ │ + │ (when tool requires confirmation) │ + │ │ + │─── POST /acp ─────────────────────>│ { method: "session/prompt", id: 4, ... } + │ Acp-Session-Id: │ + │ ┌─────────────────────│ + │<─────────────│─ SSE event ─────────│ notification: ToolCall (status: pending) + │<─────────────│─ SSE event ─────────│ { method: "request_permission", id: 99, params: {...} } + │ │ │ (server-to-client request) + │ │ │ + │─── POST /acp ┼────────────────────>│ { id: 99, result: { outcome: "allow_once" } } + │ Acp-Session-Id: │ (client response, returns 202 Accepted) + │ │ │ + │<─────────────│─ SSE event ─────────│ notification: ToolCallUpdate (status: completed) + │<─────────────│─ SSE event ─────────│ { id: 4, result: { stop_reason: "end_turn" } } + │ ▼ │ + │ │ + │ ═══ Cancel Flow ═══ │ + │ │ + │─── POST /acp ─────────────────────>│ { method: "session/prompt", id: 5, ... } + │ Acp-Session-Id: │ + │ ┌─────────────────────│ + │<─────────────│─ SSE event ─────────│ notification: AgentMessageChunk + │ │ │ + │─── POST /acp ┼────────────────────>│ { method: "session/cancel" } + │ Acp-Session-Id: │ (notification, no id - returns 202 Accepted) + │ │ │ + │<─────────────│─ SSE event ─────────│ { id: 5, result: { stop_reason: "cancelled" } } + │ ▼ │ + │ │ + │ ═══ Resume Session Flow ═══ │ + │ │ + │─── POST /acp ─────────────────────>│ { method: "initialize", id: 1 } + │ (no Acp-Session-Id) │ New HTTP session + │ ┌─────────────────────│ + │<─────────────│─ SSE event ─────────│ { id: 1, result: { capabilities } } + │ │ │ Response includes new Acp-Session-Id + │ ▼ │ + │ │ + │─── POST /acp ─────────────────────>│ { method: "session/load", id: 2, + │ Acp-Session-Id: │ params: { session_id: , cwd } } + │ ┌─────────────────────│ + │<─────────────│─ SSE event ─────────│ notification: UserMessageChunk (history replay) + │<─────────────│─ SSE event ─────────│ notification: AgentMessageChunk (history replay) + │<─────────────│─ SSE event ─────────│ notification: ToolCall (history replay) + │<─────────────│─ SSE event ─────────│ notification: ToolCallUpdate (history replay) + │<─────────────│─ SSE event ─────────│ { id: 2, result: {} } + │ ▼ │ + │ │ + │ ═══ Standalone SSE Stream ═══ │ + │ (optional, for server-initiated) │ + │ │ + │─── GET /acp ──────────────────────>│ Open dedicated SSE listener + │ Acp-Session-Id: │ + │ Accept: text/event-stream │ + │ ┌─────────────────────│ Long-lived connection for + │ │ (SSE stream open) │ server-initiated messages + │ ▼ │ + │ │ + │ ═══ Session Termination ═══ │ + │ │ + │─── DELETE /acp ───────────────────>│ Terminate session + │ Acp-Session-Id: │ + │<────────── 202 Accepted ───────────│ +``` + +#### Content Negotiation and Validation + +- `Content-Type` **MUST** be `application/json` (415 otherwise). +- `Accept` **MUST** include both `application/json` and `text/event-stream` (406 otherwise). +- Batch JSON-RPC requests return 501. + +### WebSocket Request Flow + +#### Connection Establishment (GET with Upgrade) + +``` +Client Server + │ GET /acp │ + │ Upgrade: websocket │ + │────────────────────────────────────────►│ + │ HTTP 101 Switching Protocols │ + │ Acp-Session-Id: │ + │◄────────────────────────────────────────│ + │ ══════ WebSocket Channel ══════════════│ +``` + +A new session is created on upgrade. The `Acp-Session-Id` is returned in the upgrade response headers. + +#### Bidirectional Messaging + +All messages are WebSocket text frames containing JSON-RPC. Binary frames are ignored. On disconnect, the server cleans up the session. + +### Unified Endpoint Routing + +``` +GET /acp + ├── Has Upgrade: websocket? → WebSocket handler + └── No → SSE stream handler + +POST /acp + ├── Initialize request? → Create session, return SSE + └── Has Acp-Session-Id? + ├── JSON-RPC request → Forward, return SSE + └── Notification/response → Forward, return 202 + +DELETE /acp → Terminate session +``` + +### Session Model + +``` +TransportSession { + to_agent_tx: mpsc::Sender, + from_agent_rx: Arc>>, + handle: JoinHandle<()>, +} +``` + +The agent task is spawned once per session. The transport layer adapts channels to the wire format (SSE events for HTTP, text frames for WebSocket). + +### MCP Streamable HTTP Compliance + +| MCP Requirement | ACP Implementation | Status | +|---|---|---| +| POST for all client→server messages | ✅ | Compliant | +| Accept header validation (406) | ✅ | Compliant | +| Notifications/responses return 202 | ✅ | Compliant | +| Requests return SSE stream | ✅ | Compliant | +| Session ID on initialize response | ✅ (`Acp-Session-Id`) | Compliant | +| Session ID required on subsequent requests | ✅ (400 if missing) | Compliant | +| GET opens SSE stream | ✅ | Compliant | +| DELETE terminates session | ✅ | Compliant | +| 404 for unknown sessions | ✅ | Compliant | +| Batch requests | ❌ (returns 501) | Documented deviation | +| Resumability (Last-Event-ID) | ❌ | Future work | +| Protocol version header | ❌ | Future work | + +### Deviations from MCP Streamable HTTP + +1. **Header naming**: `Acp-Session-Id` / `Acp-Protocol-Version` instead of MCP equivalents, to avoid collision when an ACP agent is also an MCP client. +2. **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. +3. **Cookie support required**: Clients MUST handle cookies on HTTP transports for the duration of the session, enabling sticky sessions and per-session server state. +4. **No batch requests**: Returns 501. May be added later. +5. **No resumability yet in reference implementation**: SSE event IDs and `Last-Event-ID` resumption planned as follow-up. + +### Implementation Plan + +1. **Phase 1 — Specification** (this RFD): Define the transport spec and align terminology. +2. **Phase 2 — Reference Implementation** (in progress): Working implementation in Goose (`block/goose`) at `crates/goose-acp/src/transport/` (`transport.rs`, `http.rs`, `websocket.rs`). +3. **Phase 3 — SDK Support**: Add Streamable HTTP and WebSocket client support to Rust SDK (`sacp`), then TypeScript SDK. +4. **Phase 4 — Hardening**: Origin validation, `Acp-Protocol-Version`, SSE resumability, batch requests, security audit. + +## Frequently asked questions + +> What questions have arisen over the course of authoring this document or during subsequent discussions? + +### Why not just use MCP Streamable HTTP as-is? + +We largely do. The only differences are header naming (`Acp-Session-Id` vs `MCP-Session-Id`) to avoid ambiguity, and the WebSocket extension for long-running agent sessions. + +### Why add WebSocket support? + +A single `prompt` can generate dozens of streaming updates and ACP is more bidirectional in nature than MCP. With Streamable HTTP, the server can only push via SSE on POST responses or a separate GET stream. WebSocket provides true bidirectional messaging, lower per-message overhead, and connection persistence. Clients MUST support WebSocket so that servers can choose to only support WebSocket connections, simplifying deployment. Streamable HTTP remains available as an additional option for environments where WebSocket is not viable on the server side (e.g., serverless). + +### How does the server distinguish WebSocket from SSE on GET? + +By inspecting the `Upgrade: websocket` header. This is standard HTTP behavior. + +### What alternative approaches did you consider, and why did you settle on this one? + +- **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; Streamable HTTP is better for stateless/serverless. + +### How does this interact with authentication? + +Authentication (see auth-methods RFD) is orthogonal and layered on top via HTTP headers, query parameters, or WebSocket subprotocols. `Acp-Session-Id` is a transport-level session identifier, not an auth token. + +### What about the `Acp-Protocol-Version` header? + +Clients SHOULD include it on all requests after initialization. Not yet implemented; part of Phase 4 hardening. + +## Revision history + +- **2025-03-10**: Initial draft based on the RFC template and goose reference implementation. From 274954b240a206e854f71e5c63e7fdd574386022 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Wed, 1 Apr 2026 12:03:52 -0400 Subject: [PATCH 2/2] rfds(streamable-http): add concept of Acp-Connection-Id and clarify streams --- .../streamable-http-websocket-transport.mdx | 190 ++++++++++++------ 1 file changed, 126 insertions(+), 64 deletions(-) diff --git a/docs/rfds/streamable-http-websocket-transport.mdx b/docs/rfds/streamable-http-websocket-transport.mdx index 38933b4c..a4f10e12 100644 --- a/docs/rfds/streamable-http-websocket-transport.mdx +++ b/docs/rfds/streamable-http-websocket-transport.mdx @@ -32,36 +32,46 @@ ACP only has stdio (inherited from MCP). There is no standard remote transport, > What are you proposing to improve the situation? -### 1. Adopts MCP Streamable HTTP semantics with ACP-specific headers +### 1. Mostly adopts MCP Streamable HTTP semantics with ACP-specific headers Follows the MCP 2025-11-25 Streamable HTTP spec with these adaptations: -- Session header: `Acp-Session-Id` (not `MCP-Session-Id`) +- Connection header: `Acp-Connection-Id` (replaces `Mcp-Session-Id`) +- Session header: `Acp-Session-Id` (new, no MCP equivalent) - Protocol version header: `Acp-Protocol-Version` (not `MCP-Protocol-Version`) - Endpoint path: conventionally `/acp` -### 2. Adds WebSocket as a first-class upgrade on the same endpoint +### 2. Separates connection identity from session identity with two headers -A GET with `Upgrade: websocket` upgrades to a persistent bidirectional channel — same endpoint, same session model. +ACP introduces two HTTP headers that together identify the full request context: -This is important for ACP, as its more bidirectional in its nature as a protocol +- **`Acp-Connection-Id`** (HTTP header) — A transport-level identifier returned by the server in the `initialize` response. It binds all subsequent HTTP requests to the initialized connection and its negotiated capabilities. This is analogous to `Mcp-Session-Id` in MCP Streamable HTTP. Required on all requests after `initialize`. +- **`Acp-Session-Id`** (HTTP header) — A session-level identifier returned by the server in the `session/new` response (both in the `Acp-Session-Id` response header and the JSON-RPC result body). It identifies a specific conversation. Clients MUST include it on all subsequent requests that operate within a session (e.g., `session/prompt`, `session/cancel`). The same value appears in JSON-RPC params as `sessionId` for methods that require it. -### 3. Requires cookie support on HTTP transports +A single `Acp-Connection-Id` may span multiple ACP sessions. Before `session/new` is called, only `Acp-Connection-Id` is present. After `session/new`, both headers are included. -Clients MUST accept, store, and return cookies set by the server on all HTTP-based transports (Streamable HTTP and WebSocket). Cookies MUST be sent on subsequent requests to the server for the duration of the session. Clients MAY discard all cookies when a session is complete. This allows servers to rely on cookies for session affinity (e.g., sticky sessions behind a load balancer) and other small amounts of per-session state. +### 3. Adds WebSocket as a first-class upgrade on the same endpoint -### 4. Defines a unified routing model +A GET with `Upgrade: websocket` upgrades to a persistent bidirectional channel — same endpoint, same lifecycle model. + +This is important for ACP, as it is more bidirectional in its nature as a protocol. + +### 4. Requires cookie support on HTTP transports + +Clients MUST accept, store, and return cookies set by the server on all HTTP-based transports (Streamable HTTP and WebSocket). Cookies MUST be sent on subsequent requests to the server for the duration of the connection. Clients MAY discard all cookies when a connection is terminated. This allows servers to rely on cookies for session affinity (e.g., sticky sessions behind a load balancer) and other small amounts of per-connection state. + +### 5. Defines a unified routing model | Method | Upgrade Header? | Behavior | |--------|-----------------|----------| | `POST` | — | Send JSON-RPC request/notification/response (Streamable HTTP) | -| `GET` | No | Open SSE stream for server-initiated messages (Streamable HTTP) | +| `GET` | No | Open session-scoped SSE stream for server-initiated messages (Streamable HTTP). Requires `Acp-Connection-Id` and `Acp-Session-Id`. | | `GET` | `Upgrade: websocket` | Upgrade to WebSocket for full-duplex messaging | -| `DELETE` | — | Terminate the session | +| `DELETE` | — | Terminate the connection | -### 5. Preserves the full ACP lifecycle +### 6. Preserves the full ACP lifecycle -The `initialize` → `initialized` → messages → close lifecycle is identical regardless of transport. Session state is keyed by `Acp-Session-Id` and is transport-agnostic. +The `initialize` → `initialized` → messages → close lifecycle is identical regardless of transport. The `Acp-Connection-Id` header binds requests to the initialized connection and its negotiated capabilities. The `Acp-Session-Id` header (introduced after `session/new`) identifies the active ACP session. Both are transport-agnostic concepts. ## Shiny future @@ -70,7 +80,6 @@ The `initialize` → `initialized` → messages → close lifecycle is identical - **SDK implementers** get a clear, testable transport spec — Rust, TypeScript, and Python SDKs can all interoperate. - **Desktop clients** use WebSocket for low-latency streaming; all clients support it as a baseline. - **Cloud deployments** expose agents behind standard HTTP load balancers using the stateless-friendly HTTP mode, with cookie-based sticky sessions guaranteed by client support. -- **MCP compatibility** is maintained — the HTTP transport is a superset of MCP Streamable HTTP. - **Proxy chains** can route ACP traffic over HTTP for multi-hop agent topologies. ## Implementation details and plan @@ -86,7 +95,7 @@ The `initialize` → `initialized` → messages → close lifecycle is identical │ │ ┌───────────▼──┐ ┌────▼──────────────┐ │ HTTP State │ │ WebSocket State │ - │ (sessions) │ │ (connections) │ + │(connections) │ │ (connections) │ └───────┬──────┘ └────┬──────────────┘ │ │ ┌───────▼──────────────▼───────────────┐ @@ -95,32 +104,60 @@ The `initialize` → `initialized` → messages → close lifecycle is identical └─────────────────────────────────────┘ ``` +### Identity Model + +ACP over Streamable HTTP uses two HTTP headers that together identify the full request context: + +| | `Acp-Connection-Id` | `Acp-Session-Id` | +|---|---|---| +| **Purpose** | Binds HTTP requests to an initialized connection | Identifies an ACP conversation/session | +| **Created by** | Server, on `initialize` response | Server, on `session/new` response | +| **Location** | HTTP header (all requests after `initialize`) | HTTP header (all requests after `session/new`) + JSON-RPC body as `sessionId` | +| **Scope** | One per `initialize` handshake | One per `session/new` call | +| **Multiplicity** | May span multiple sessions | Belongs to exactly one connection | +| **Used for** | Request routing, capability lookup | Session-scoped request routing, session-scoped GET listener filtering | +| **Required** | After `initialize` | After `session/new` | + ### Streamable HTTP Message Flow ``` Client Server │ │ - │ ═══ Session Initialization ═══ │ + │ ═══ Connection Initialization ═══│ │ │ │─── POST /acp ─────────────────────>│ { method: "initialize", id: 1 } - │ Accept: application/json, │ (no Acp-Session-Id header) + │ Accept: application/json, │ (no Acp-Connection-Id header) │ text/event-stream │ - │ ┌─────────────────────│ Server creates session, opens SSE stream + │ ┌─────────────────────│ Server creates connection, opens SSE stream │ │ (SSE stream open) │ │<─────────────│─ SSE event ─────────│ { id: 1, result: { capabilities } } - │ │ │ Response includes Acp-Session-Id header + │ │ │ Response includes Acp-Connection-Id header │ ▼ │ │ │ - │ ═══ Prompt Flow ═══ │ + │ ═══ Session Creation ═══ │ │ │ │─── POST /acp ─────────────────────>│ { method: "session/new", id: 2, - │ Acp-Session-Id: │ params: { cwd, mcp_servers } } + │ Acp-Connection-Id: │ params: { cwd, mcpServers } } │ ┌─────────────────────│ Opens new SSE stream for response - │<─────────────│─ SSE event ─────────│ { id: 2, result: { session_id: } } + │<─────────────│─ SSE event ─────────│ { id: 2, result: { sessionId: "sess_abc123" } } + │ │ │ Response includes Acp-Session-Id header │ ▼ │ │ │ + │ ═══ Optional: GET Listener ════════│ + │ │ + │─── GET /acp ──────────────────────>│ Open SSE listener for + │ Acp-Connection-Id: │ server-initiated messages + │ Acp-Session-Id: │ (scoped to this session) + │ Accept: text/event-stream │ + │ ┌─────────────────────│ Only events for this session + │ │ (SSE stream open) │ are delivered on this stream + │ ▼ │ + │ │ + │ ═══ Prompt Flow ═══ │ + │ │ │─── POST /acp ─────────────────────>│ { method: "session/prompt", id: 3, - │ Acp-Session-Id: │ params: { session_id, prompt } } + │ Acp-Connection-Id: │ params: { sessionId: "sess_abc123", prompt } } + │ Acp-Session-Id: │ │ ┌─────────────────────│ Opens new SSE stream for response │<─────────────│─ SSE event ─────────│ notification: AgentMessageChunk │<─────────────│─ SSE event ─────────│ notification: AgentThoughtChunk (if reasoning) @@ -134,6 +171,7 @@ Client Server │ (when tool requires confirmation) │ │ │ │─── POST /acp ─────────────────────>│ { method: "session/prompt", id: 4, ... } + │ Acp-Connection-Id: │ │ Acp-Session-Id: │ │ ┌─────────────────────│ │<─────────────│─ SSE event ─────────│ notification: ToolCall (status: pending) @@ -141,7 +179,8 @@ Client Server │ │ │ (server-to-client request) │ │ │ │─── POST /acp ┼────────────────────>│ { id: 99, result: { outcome: "allow_once" } } - │ Acp-Session-Id: │ (client response, returns 202 Accepted) + │ Acp-Connection-Id: │ (client response, returns 202 Accepted) + │ Acp-Session-Id: │ │ │ │ │<─────────────│─ SSE event ─────────│ notification: ToolCallUpdate (status: completed) │<─────────────│─ SSE event ─────────│ { id: 4, result: { stop_reason: "end_turn" } } @@ -150,28 +189,33 @@ Client Server │ ═══ Cancel Flow ═══ │ │ │ │─── POST /acp ─────────────────────>│ { method: "session/prompt", id: 5, ... } + │ Acp-Connection-Id: │ │ Acp-Session-Id: │ │ ┌─────────────────────│ │<─────────────│─ SSE event ─────────│ notification: AgentMessageChunk │ │ │ │─── POST /acp ┼────────────────────>│ { method: "session/cancel" } - │ Acp-Session-Id: │ (notification, no id - returns 202 Accepted) + │ Acp-Connection-Id: │ (notification, no id - returns 202 Accepted) + │ Acp-Session-Id: │ │ │ │ │<─────────────│─ SSE event ─────────│ { id: 5, result: { stop_reason: "cancelled" } } │ ▼ │ │ │ │ ═══ Resume Session Flow ═══ │ + │ (new connection, existing session)│ │ │ │─── POST /acp ─────────────────────>│ { method: "initialize", id: 1 } - │ (no Acp-Session-Id) │ New HTTP session + │ (no Acp-Connection-Id) │ New connection │ ┌─────────────────────│ │<─────────────│─ SSE event ─────────│ { id: 1, result: { capabilities } } - │ │ │ Response includes new Acp-Session-Id + │ │ │ Response includes new Acp-Connection-Id │ ▼ │ │ │ │─── POST /acp ─────────────────────>│ { method: "session/load", id: 2, - │ Acp-Session-Id: │ params: { session_id: , cwd } } - │ ┌─────────────────────│ + │ Acp-Connection-Id: │ params: { sessionId: "sess_abc123", cwd } } + │ Acp-Session-Id: │ + │ │ │ + │ ┌─────────────────────│ Response includes Acp-Session-Id header │<─────────────│─ SSE event ─────────│ notification: UserMessageChunk (history replay) │<─────────────│─ SSE event ─────────│ notification: AgentMessageChunk (history replay) │<─────────────│─ SSE event ─────────│ notification: ToolCall (history replay) @@ -179,20 +223,10 @@ Client Server │<─────────────│─ SSE event ─────────│ { id: 2, result: {} } │ ▼ │ │ │ - │ ═══ Standalone SSE Stream ═══ │ - │ (optional, for server-initiated) │ - │ │ - │─── GET /acp ──────────────────────>│ Open dedicated SSE listener - │ Acp-Session-Id: │ - │ Accept: text/event-stream │ - │ ┌─────────────────────│ Long-lived connection for - │ │ (SSE stream open) │ server-initiated messages - │ ▼ │ + │ ═══ Connection Termination ═══ │ │ │ - │ ═══ Session Termination ═══ │ - │ │ - │─── DELETE /acp ───────────────────>│ Terminate session - │ Acp-Session-Id: │ + │─── DELETE /acp ───────────────────>│ Terminate connection + │ Acp-Connection-Id: │ │<────────── 202 Accepted ───────────│ ``` @@ -212,44 +246,60 @@ Client Server │ Upgrade: websocket │ │────────────────────────────────────────►│ │ HTTP 101 Switching Protocols │ - │ Acp-Session-Id: │ + │ Acp-Connection-Id: │ │◄────────────────────────────────────────│ │ ══════ WebSocket Channel ══════════════│ ``` -A new session is created on upgrade. The `Acp-Session-Id` is returned in the upgrade response headers. +A new connection is created on upgrade. The `Acp-Connection-Id` is returned in the upgrade response headers. The client must still send `initialize` as the first JSON-RPC message over the WebSocket to negotiate capabilities before creating sessions. #### Bidirectional Messaging -All messages are WebSocket text frames containing JSON-RPC. Binary frames are ignored. On disconnect, the server cleans up the session. +All messages are WebSocket text frames containing JSON-RPC. Binary frames are ignored. On disconnect, the server cleans up the connection and any associated sessions. ### Unified Endpoint Routing ``` GET /acp ├── Has Upgrade: websocket? → WebSocket handler - └── No → SSE stream handler + └── No → SSE stream handler (requires Acp-Connection-Id and Acp-Session-Id) + ├── Missing Acp-Connection-Id or Acp-Session-Id? → 400 Bad Request + └── Valid headers → Session-scoped stream (only events for that session) POST /acp - ├── Initialize request? → Create session, return SSE - └── Has Acp-Session-Id? + ├── Initialize request? → Create connection, return SSE with Acp-Connection-Id + ├── No Acp-Connection-Id? → 400 Bad Request + ├── Unknown Acp-Connection-Id? → 404 Not Found + └── Has valid Acp-Connection-Id + ├── session/new or session/load → Forward, return SSE with Acp-Session-Id + ├── Session method without Acp-Session-Id → 400 Bad Request ├── JSON-RPC request → Forward, return SSE └── Notification/response → Forward, return 202 -DELETE /acp → Terminate session +DELETE /acp + ├── Has Acp-Connection-Id? → Terminate connection and all associated sessions + └── No → 400 Bad Request ``` ### Session Model ``` -TransportSession { - to_agent_tx: mpsc::Sender, - from_agent_rx: Arc>>, - handle: JoinHandle<()>, +Connection { + connection_id: String, // Acp-Connection-Id + capabilities: NegotiatedCapabilities, + sessions: HashMap, // keyed by Acp-Session-Id + to_agent_tx: mpsc::Sender, + from_agent_rx: Arc>>, + handle: JoinHandle<()>, +} + +Session { + session_id: String, // Acp-Session-Id / sessionId + get_listeners: Vec, // GET SSE streams for this session } ``` -The agent task is spawned once per session. 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. Sessions are created within a connection via `session/new`, which returns the `Acp-Session-Id` in both the HTTP response header and the JSON-RPC result body. The transport layer adapts channels to the wire format (SSE events for HTTP, text frames for WebSocket). GET listeners are always session-scoped — both `Acp-Connection-Id` and `Acp-Session-Id` are required, and the server delivers only events belonging to that session. ### MCP Streamable HTTP Compliance @@ -259,22 +309,24 @@ The agent task is spawned once per session. The transport layer adapts channels | Accept header validation (406) | ✅ | Compliant | | Notifications/responses return 202 | ✅ | Compliant | | Requests return SSE stream | ✅ | Compliant | -| Session ID on initialize response | ✅ (`Acp-Session-Id`) | Compliant | -| Session ID required on subsequent requests | ✅ (400 if missing) | Compliant | +| Session ID on initialize response | ✅ (`Acp-Connection-Id`) | Compliant (renamed) | +| Session ID required on subsequent requests | ✅ (`Acp-Connection-Id` required; `Acp-Session-Id` required after `session/new`) | Compliant (extended) | | GET opens SSE stream | ✅ | Compliant | -| DELETE terminates session | ✅ | Compliant | -| 404 for unknown sessions | ✅ | Compliant | +| DELETE terminates session | ✅ (terminates connection) | Compliant | +| 404 for unknown sessions | ✅ (unknown connection IDs) | Compliant | | Batch requests | ❌ (returns 501) | Documented deviation | | Resumability (Last-Event-ID) | ❌ | Future work | | Protocol version header | ❌ | Future work | ### Deviations from MCP Streamable HTTP -1. **Header naming**: `Acp-Session-Id` / `Acp-Protocol-Version` instead of MCP equivalents, to avoid collision when an ACP agent is also an MCP client. -2. **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. -3. **Cookie support required**: Clients MUST handle cookies on HTTP transports for the duration of the session, enabling sticky sessions and per-session server state. -4. **No batch requests**: Returns 501. May be added later. -5. **No resumability yet in reference implementation**: SSE event IDs and `Last-Event-ID` resumption planned as follow-up. +1. **Header naming**: `Acp-Connection-Id` / `Acp-Session-Id` / `Acp-Protocol-Version` instead of MCP equivalents. `Acp-Connection-Id` maps to MCP's `Mcp-Session-Id` but is deliberately renamed to avoid confusion with ACP's session concept (see [Identity Model](#identity-model)). +2. **Two-header model**: MCP uses a single `Mcp-Session-Id` for both transport binding and session identity. ACP separates these into `Acp-Connection-Id` (connection-scoped, from `initialize`) and `Acp-Session-Id` (session-scoped, from `session/new`), because ACP sessions are an explicit protocol concept with their own lifecycle (`session/new`, `session/load`, `session/cancel`). The `Acp-Session-Id` also enables session-scoped GET listener streams. +3. **Session-scoped GET streams**: GET listeners require both `Acp-Connection-Id` and `Acp-Session-Id`. The server MUST only deliver events belonging to that session. There is no connection-scoped GET stream. MCP has no equivalent concept. +4. **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. +5. **Cookie support required**: Clients MUST handle cookies on HTTP transports for the duration of the connection, enabling sticky sessions and per-connection server state. +6. **No batch requests**: Returns 501. May be added later. +7. **No resumability yet in reference implementation**: SSE event IDs and `Last-Event-ID` resumption planned as follow-up. ### Implementation Plan @@ -289,7 +341,11 @@ The agent task is spawned once per session. The transport layer adapts channels ### Why not just use MCP Streamable HTTP as-is? -We largely do. The only differences are header naming (`Acp-Session-Id` vs `MCP-Session-Id`) to avoid ambiguity, and the WebSocket extension for long-running agent sessions. +We largely do. The differences are: header naming (`Acp-Connection-Id` + `Acp-Session-Id` vs MCP's single `Mcp-Session-Id`), the WebSocket extension for long-running agent sessions, the two-header model that separates connection identity from session identity, and session-scoped GET listener streams. + +### Why two headers (`Acp-Connection-Id` and `Acp-Session-Id`) instead of one? + +MCP uses a single `Mcp-Session-Id` because MCP has no protocol-level concept of sessions. ACP does — `session/new` creates a conversation with its own lifecycle. Using one header for both would conflate the initialized connection (capabilities, protocol version, auth state) with the active session (conversation context, history). Two headers let the server immediately distinguish which connection *and* which session a request belongs to, enable session-scoped GET listener streams, and support multiple concurrent sessions within a single connection. ### Why add WebSocket support? @@ -299,14 +355,19 @@ A single `prompt` can generate dozens of streaming updates and ACP is more bidir 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 `Acp-Session-Id`. The client includes the appropriate `Acp-Session-Id` header (and `sessionId` in the JSON-RPC params) on subsequent requests. The `Acp-Connection-Id` header remains the same across all of them. The client may also open separate GET listener streams per session, each requiring both `Acp-Connection-Id` and `Acp-Session-Id`. + ### What alternative approaches did you consider, and why did you settle on this one? - **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; Streamable HTTP is better for stateless/serverless. +- **Single header for both connection and session**: Rejected — conflates connection lifecycle with session lifecycle, prevents session-scoped GET streams, and makes multi-session connections ambiguous. ### How does this interact with authentication? -Authentication (see auth-methods RFD) is orthogonal and layered on top via HTTP headers, query parameters, or WebSocket subprotocols. `Acp-Session-Id` is a transport-level session identifier, not an auth token. +Authentication (see auth-methods RFD) is orthogonal and layered on top via HTTP headers, query parameters, or WebSocket subprotocols. `Acp-Connection-Id` and `Acp-Session-Id` are transport-level identifiers, not auth tokens. ### What about the `Acp-Protocol-Version` header? @@ -315,3 +376,4 @@ Clients SHOULD include it on all requests after initialization. Not yet implemen ## Revision history - **2025-03-10**: Initial draft based on the RFC template and goose reference implementation. +- **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`.