Implement Streamable HTTP properly (persistent SSE, sessions) #16
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implement Streamable HTTP properly — persistent server-initiated SSE channel, session resumption via
Mcp-Session-Id, and the full bidirectional message flow the spec requires.Goal
lmcp's current
/mcphandlers are request-response only:GET /mcpopens an SSE connection, sends oneendpointevent, thenclient:close()(lmcp.lua:240–244). Nothing server-initiated can flow.POST /mcphandles a single JSON-RPC request, optionally emits the response as a single SSE event, then closes.Mcp-Session-Idis in the CORS allow-list but not issued or honoured.The spec's Streamable HTTP transport keeps the GET connection open as a long-lived event stream, lets the server push notifications/requests to the client at any time, and binds them to a session via
Mcp-Session-Id. Many of the other open issues (Sampling, Roots, Progress, Cancellation, Logging delivery) are gated on this.Required behaviour
POST /mcpPOST /mcpGET /mcpnotifications/*and server-initiated requests (sampling, roots/list) at any time. Client correlates by id.DELETE /mcpMcp-Session-Idheader is absent on the first non-initializerequest, return 400 / re-init. Server issues it in theinitializeresponse.Implementation outline
sessions[session_id] = { client, queue }map.queueis a list of pending server→client messages.GET /mcpno longer closes — itclient:settimeout(0)and loops, flushingqueueas messages arrive. Loop exits on client TCP close.POST /mcpenqueues responses on the session's queue if there's a live GET stream, else writes them inline.os.time() .. "-" .. random) oninitializeand write it back inMcp-Session-Idresponse header.POST /mcpand are matched by the session's id→handler map.Concerns
select. This is the load-bearing complexity of the rewrite.Scope (v1)
/mcpwith session-id issue + honour.select-based event loop (no luasocket coroutine framework dependency).Out of scope
Last-Event-IDreconnect-resume semantics (defer to a v2).Depends on / unblocks
Priority
High in terms of leverage. Without it, half of the other issues are noop. But it's also the largest single rewrite on the list — 1–2 days of careful work plus testing.
Implemented. Replaced the accept-serialised
lmcp:run()with a select()-based event loop and per-connection FSM (reading_head → reading_body → dispatching → writing | sse_open). Highlights:Delivered:
initializeissuesMcp-Session-Id; subsequent requests carry it. Unknown id on non-initialize → 404 per spec. Sessionless POST auto-issues (backwards compat).: keep-alive) on every open stream._notify_queuefans out to ALL open SSE; per-sessionsess.notify_qonly to that session (used by the newserver:server_request()helper for sampling/roots)._find_pendingroutes incoming POST that is actually a server-initiated response to its callback.write_buf(append-only /:sub(offset+1)invariant documented in code).Honest limit: the event loop concurrencies I/O, NOT handler execution. A synchronous tool handler (
shell sleep 3) blocks the loop for its duration; concurrent fast POSTs serialise behind it. My Phase 1 success criterion #4 ("fast POST doesn't wait for slow POST") was an overreach — this is a different rewrite. Filed as follow-up #20 — Concurrent handler dispatch.Unblocks the bidirectional-transport half of issues #9 (sampling), #10 (roots), and the delivery path for #8 (logging notifications) and #11 (progress).
Memory:
project_io_vs_handler_concurrency.mdcaptures the I/O-vs-handler distinction so future work doesn't reintroduce the confusion.