Summary
In the MCP SSE flow, POST /message?sessionId=... can return 202 Accepted after the request is placed into the per-session MPSC queue. The actual MCP response is produced later in a background goroutine and then enqueued into SSEServer.eventQueue.
If the SSE event queue is full, SSEServer.HandleMessage returns event queue is full, but processMCPSSEMpscMessages currently ignores that error and continues. From the MCP client's perspective, the POST request was accepted, but the corresponding JSON-RPC response may never arrive on the SSE connection.
Code path
Current main at 4963684929bfbb9071aea4400fd75593336bf7a7:
More detailed trace:
-
The routes are split between the SSE connection and the message endpoint.
core/router/mcp.go#L22-L23:
22 router.GET("/sse", middleware.MCPAuth, mcp.HostMCPSSEServer)
23 router.POST("/message", mcp.MCPMessage)
GET /sse opens the SSE channel, while POST /message sends later JSON-RPC messages for that session.
-
When the SSE connection is opened, a session and an SSEServer are created:
core/controller/mcp/mcp.go#L30-L52:
30 // Store the session
31 store := getStore()
32 newSession := store.New()
33
34 newEndpoint := endpoint.NewEndpoint(newSession)
35 server := mcpproxy.NewSSEServer(
36 s,
37 mcpproxy.WithMessageEndpoint(newEndpoint),
38 )
39
40 store.Set(newSession, mcpType)
...
48 // Start message processing goroutine
49 go processMCPSSEMpscMessages(ctx, newSession, server)
50
51 // Handle SSE connection
52 server.ServeHTTP(c.Writer, c.Request)
This means the POST handler and the SSE writer are connected asynchronously through the session.
-
The SSE server has a bounded event queue:
core/mcpproxy/sse.go#L49-L57:
49 // NewSSEServer creates a new SSE server instance with the given MCP server and options.
50 func NewSSEServer(server mcpservers.Server, opts ...SSEOption) *SSEServer {
51 s := &SSEServer{
52 server: server,
53 messageEndpoint: "/message",
54 keepAlive: false,
55 keepAliveInterval: 30 * time.Second,
56 eventQueue: make(chan string, 100),
57 }
core/mcpproxy/sse.go#L123-L132:
123 // Main event loop - this runs in the HTTP handler goroutine
124 for {
125 select {
126 case event := <-s.eventQueue:
127 // Write the event to the response
128 fmt.Fprint(w, event)
129 flusher.Flush()
130 case <-r.Context().Done():
131 return
132 }
ServeHTTP drains eventQueue and writes events to the client. If the SSE client is slow or blocked, this queue can fill.
-
POST /message only confirms that the request was put into the request-side MPSC queue:
core/controller/mcp/mcp.go#L115-L136:
115 func sendMCPSSEMessage(c *gin.Context, sessionID string) {
116 _, ok := getStore().Get(sessionID)
117 if !ok {
118 http.Error(c.Writer, "invalid session", http.StatusBadRequest)
119 return
120 }
...
130 err = mpscInstance.send(c.Request.Context(), sessionID, body)
131 if err != nil {
132 http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
133 return
134 }
135
136 c.Writer.WriteHeader(http.StatusAccepted)
core/controller/mcp/mcp-mpsc.go#L126-L136:
126 func (c *channelMCPMpsc) send(ctx context.Context, id string, data []byte) error {
127 ch := c.getOrCreateChannel(id)
128
129 select {
130 case ch <- data:
131 return nil
132 case <-ctx.Done():
133 return ctx.Err()
134 default:
135 return fmt.Errorf("channel buffer full for session %s", id)
136 }
The request-side queue can report backpressure to /message, but this does not cover the later response enqueue into SSE.
-
The background goroutine then processes the request and tries to enqueue the response:
core/controller/mcp/mcp.go#L61-L75:
61 mpscInstance := getMCPMpsc()
62 for {
63 select {
64 case <-ctx.Done():
65 return
66 default:
67 data, err := mpscInstance.recv(ctx, sessionID)
68 if err != nil {
69 return
70 }
71
72 if err := server.HandleMessage(ctx, data); err != nil {
73 continue
74 }
75 }
core/mcpproxy/sse.go#L138-L160:
138 func (s *SSEServer) HandleMessage(ctx context.Context, req []byte) error {
139 // Process message through MCPServer
140 response := s.server.HandleMessage(ctx, req)
...
153 // Queue the event for sending via SSE
154 select {
155 case s.eventQueue <- message:
156 // Event queued successfully
157 default:
158 // Queue is full
159 return errors.New("event queue is full")
160 }
The response-side enqueue can fail when eventQueue is full.
-
That response-side enqueue error is currently swallowed:
core/controller/mcp/mcp.go#L72-L74:
72 if err := server.HandleMessage(ctx, data); err != nil {
73 continue
74 }
Therefore event queue is full is not surfaced to the client and is not logged here.
So the failure can happen after /message has already accepted the request. In other words:
- request enqueue succeeds;
/message returns 202 Accepted;
- the MCP server processes the request;
- response enqueue into the SSE event queue fails;
- the error is ignored by the background loop;
- the client may never receive the response.
The same SSE processing pattern also appears in the embedded MCP SSE path:
Why this matters
MCP clients and agent frameworks rely on request/response pairing for tool calls. If a response is silently dropped:
- the client may wait until timeout even though the server processed the request;
- an agent may treat a tool call as failed or stuck;
- repeated requests can create duplicated side effects if the client retries;
- operators have no visible signal that the SSE session lost responses.
This is especially likely to matter with slow SSE clients, transient network backpressure, or bursty MCP tool calls.
For a relay/proxy, this kind of failure is hard to diagnose because the upstream MCP server may have processed the message correctly, while the downstream client only sees a missing SSE response.
Suggested fixes
Some possible directions:
- Do not silently ignore
server.HandleMessage errors in processMCPSSEMpscMessages; log them with session context at minimum.
- Consider closing/marking the SSE session unhealthy when
eventQueue overflows, so clients reconnect instead of waiting for a missing response.
- If possible, apply backpressure before returning
202 Accepted, or otherwise make response delivery failure visible to the client.
- Track a dropped/overflow counter so operators can detect this condition.
Reproduction idea
One possible test:
- Open an MCP SSE session.
- Make the SSE reader slow or blocked so
eventQueue is not drained quickly.
- POST more than 100 JSON-RPC requests to
/message?sessionId=....
- Observe that some POSTs may return
202 Accepted, while their corresponding JSON-RPC responses never appear on the SSE stream.
I may be missing intended behavior here, but it looks like an accepted MCP request can currently lose its response without any visible error.
Summary
In the MCP SSE flow,
POST /message?sessionId=...can return202 Acceptedafter the request is placed into the per-session MPSC queue. The actual MCP response is produced later in a background goroutine and then enqueued intoSSEServer.eventQueue.If the SSE event queue is full,
SSEServer.HandleMessagereturnsevent queue is full, butprocessMCPSSEMpscMessagescurrently ignores that error and continues. From the MCP client's perspective, the POST request was accepted, but the corresponding JSON-RPC response may never arrive on the SSE connection.Code path
Current
mainat4963684929bfbb9071aea4400fd75593336bf7a7:More detailed trace:
The routes are split between the SSE connection and the message endpoint.
core/router/mcp.go#L22-L23:GET /sseopens the SSE channel, whilePOST /messagesends later JSON-RPC messages for that session.When the SSE connection is opened, a session and an
SSEServerare created:core/controller/mcp/mcp.go#L30-L52:This means the POST handler and the SSE writer are connected asynchronously through the session.
The SSE server has a bounded event queue:
core/mcpproxy/sse.go#L49-L57:core/mcpproxy/sse.go#L123-L132:ServeHTTPdrainseventQueueand writes events to the client. If the SSE client is slow or blocked, this queue can fill.POST /messageonly confirms that the request was put into the request-side MPSC queue:core/controller/mcp/mcp.go#L115-L136:core/controller/mcp/mcp-mpsc.go#L126-L136:The request-side queue can report backpressure to
/message, but this does not cover the later response enqueue into SSE.The background goroutine then processes the request and tries to enqueue the response:
core/controller/mcp/mcp.go#L61-L75:core/mcpproxy/sse.go#L138-L160:The response-side enqueue can fail when
eventQueueis full.That response-side enqueue error is currently swallowed:
core/controller/mcp/mcp.go#L72-L74:Therefore
event queue is fullis not surfaced to the client and is not logged here.So the failure can happen after
/messagehas already accepted the request. In other words:/messagereturns202 Accepted;The same SSE processing pattern also appears in the embedded MCP SSE path:
core/controller/mcp/embedmcp.go#L450-L471Why this matters
MCP clients and agent frameworks rely on request/response pairing for tool calls. If a response is silently dropped:
This is especially likely to matter with slow SSE clients, transient network backpressure, or bursty MCP tool calls.
For a relay/proxy, this kind of failure is hard to diagnose because the upstream MCP server may have processed the message correctly, while the downstream client only sees a missing SSE response.
Suggested fixes
Some possible directions:
server.HandleMessageerrors inprocessMCPSSEMpscMessages; log them with session context at minimum.eventQueueoverflows, so clients reconnect instead of waiting for a missing response.202 Accepted, or otherwise make response delivery failure visible to the client.Reproduction idea
One possible test:
eventQueueis not drained quickly./message?sessionId=....202 Accepted, while their corresponding JSON-RPC responses never appear on the SSE stream.I may be missing intended behavior here, but it looks like an accepted MCP request can currently lose its response without any visible error.