NeoGraph 0.16.0
A C++20 Graph Agent Engine Library — LangGraph for C++
Loading...
Searching...
No Matches
http_server.h File Reference

EXPERIMENTAL remote transports for ACPServer: Streamable HTTP and WebSocket on one endpoint. More...

#include <neograph/api.h>
#include <neograph/acp/server.h>
#include <chrono>
#include <cstddef>
#include <functional>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
Include dependency graph for http_server.h:

Go to the source code of this file.

Classes

struct  neograph::acp::ACPHttpConfig
 Limits, Host and Origin rules and authentication of an ACPHttpServer. More...
 
struct  neograph::acp::ACPHttpConnection
 What the factory of an ACPHttpServer returns for one new connection. More...
 
class  neograph::acp::ACPHttpServer
 ACP over Streamable HTTP and WebSocket; see the file comment for the mapping, the deviations from the draft and the security rules. More...
 

Detailed Description

EXPERIMENTAL remote transports for ACPServer: Streamable HTTP and WebSocket on one endpoint.

Status. stdio (ACPServer::run) is the standard ACP transport and is unchanged. The protocol has no remote transport yet: the Streamable HTTP and WebSocket transport is a draft proposal (the "Streamable HTTP & WebSocket Transport" RFD of the ACP project). This class follows that draft where it is explicit, so its wire details may change when the draft does. It is off by default: a separate library (neograph::acp_http_server, CMake option NEOGRAPH_BUILD_ACP_HTTP_SERVER, default OFF) that nothing links unless asked, and a server that listens only after start().

Design.

One ACP connection is one ACPServer. Everything ACP keeps per connection (initialize once, the negotiated client capabilities, sessions, in-flight prompts, the ids of the agent's own requests) already lives in ACPServer, so the transport builds one per connection with the factory and drives it exactly as stdio does: ACPServer::run() over a queue-backed stream pair. A message reaches the agent in the order the transport received it, and a connection that ends is an end of input for run(), so the prompts of a dropped connection are cancelled after ACPServer's EOF grace (the transport shortens it to zero when it closes a connection on purpose). No ACPServer code path changes.

Streamable HTTP on endpoint (default /acp), per the draft:

  • POST an initialize request without Acp-Connection-Id creates a connection and is answered 200 with the JSON-RPC response as the body and the new id in the Acp-Connection-Id header. Every other POST needs Acp-Connection-Id, is answered 202 with an empty body, and its response, like every other message of the agent, travels on a stream.
  • GET with Accept: text/event-stream opens a server-sent-event stream: with Acp-Connection-Id alone, the connection stream (the responses to session/new and session/resume, and messages that belong to no session); with Acp-Session-Id too, the session stream (session/update, agent-to-client requests such as session/request_permission and fs/*, and the responses to the session's other requests, found by their JSON-RPC id). A session that session/resume brought back has no stream of its own until the client opens one; its messages use the connection stream meanwhile. A second stream on a scope replaces the first. Each message is one data: event; a quiet stream sends the comment : keep-alive.
  • DELETE with Acp-Connection-Id ends the connection (202).
  • A POST that carries params.sessionId (other than session/resume, which introduces a session) must carry the same Acp-Session-Id, and it must name a session of that connection, or the answer is 400 or
    1. Content type application/json (415), Accept (406), a batch (501) and unknown ids (404) are answered as the draft lists. The client's answer to a request of the agent is a POST of a response; the session header is optional on it.
  • A message the agent emits while no stream of its scope is open waits in a bounded queue and is sent when one opens; the draft defines no replay, so a message dropped with its connection is lost, and a connection whose queue overflows is closed.

WebSocket on the same endpoint: a GET with Upgrade: websocket creates a connection whose messages are text frames, one JSON-RPC message each, in both directions; a binary frame is ignored. The client sends initialize as its first message. Frames follow RFC 6455: client frames must be masked, control frames are answered, a message may be fragmented, and a message larger than max_payload_bytes closes the connection with code 1009 (the HTTP library reads a frame of up to 16 MiB before this limit can apply). Closing the socket ends the connection; a peer that stops sending for more than a few seconds in the middle of a frame is disconnected.

Connections are independent and may run prompts concurrently on the same engine. A session id belongs to one live connection: session/resume of an id that another live connection holds is refused with the JSON-RPC error that a busy session gets. A connection's sessions die with it; a client that reconnects recovers them with session/resume from the engine's checkpoint store, as it would after restarting a stdio agent. An ACPClient made by client() reaches the connection that owns a session.

Where this deviates from the draft.

  • The draft requires HTTP/2. This server speaks HTTP/1.1, so every stream and every POST is its own TCP connection. A deployment that needs multiplexing terminates HTTP/2 in a reverse proxy. WebSocket does not have this limit.
  • The 101 response of a WebSocket upgrade does not carry Acp-Connection-Id (the HTTP library does not let a handler add headers to it); the socket is the connection, and no later request needs the id.
  • No cookies are set, Acp-Protocol-Version is not checked, and there is no event id or resumption of a stream.
  • A stream for a session id the connection does not know is refused (404), as the draft's routing table says, so a session stream opens after session/new or session/resume has answered.

Security. The listener binds a loopback address unless the caller names another host, and a remote bind is refused unless ACPHttpConfig::authenticator is set. Every request, including a stream, an upgrade and a path the server does not serve, is checked before its body is read: Host and Origin (the rules and the configuration names of MCPHttpServerConfig and A2AHttpConfig), then the authenticator, then the size limit. A connection belongs to the principal that created it. The agent can read and write files and run commands in the client's name, so treat a remote bind as exposing that. A client cannot make the server start processes: mcpServers in session/new and session/resume is refused unless ACPHttpConfig::allow_client_mcp_servers is set, and the cwd it names reaches the graph as _acp_cwd, so a node that uses it on the server has to check it. The bytes of messages waiting for the agent are bounded (two messages of max_payload_bytes, at least 1 MiB; a POST beyond it is answered 503 with Retry-After, and a WebSocket message beyond it with a JSON-RPC error). Slow or idle TCP connections are not limited per peer: a server that faces the internet belongs behind a reverse proxy that does that.

Definition in file http_server.h.