MCP SSE /message can return 202 even when the response is later dropped by a full SSE event queue
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 58/100
Research direction
Start with processMCPSSEMpscMessages in core/controller/mcp/mcp.go and compare its error handling with the embedded path in core/controller/mcp/embedmcp.go. Read SSEServer.HandleMessage in core/mcpproxy/sse.go and the /message flow in mcp.go, then reproduce overflow with a blocked SSE reader. Done means response-queue failures are visible or handled according to an agreed behavior, with coverage for the overflow case.
Written by the indexing model from the issue text.
Description
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.
22 router.GET("/sse", middleware.MCPAuth, mcp.HostMCPSSEServer) 23 router.POST("/message", mcp.MCPMessage)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: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:
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 }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: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
eventQueueis 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 fullis 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;
/messagereturns202 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.HandleMessageerrors inprocessMCPSSEMpscMessages; log them with session context at minimum. - Consider closing/marking the SSE session unhealthy when
eventQueueoverflows, 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
eventQueueis 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.
- Dominant language
- Go
- Stars
- 561
- Forks
- 114
- Avg merge
- 2h 58m
- Merged PRs (30d)
- 11
Getting set up
- Ships a Dockerfile or Docker Compose file
- No pull request template
- No contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from labring/aiproxy
-
Difficulty 5/5 Over a week Newbie friendliness 35/100
Maintainers usually reply within 1 day
-
Difficulty 4/5 3-5 days Newbie friendliness 35/100
labring/aiproxy#633 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 58/100
Maintainers usually reply within 1 day
-
功能请求:增加渠道请求的并发限制Open
Difficulty 4/5 3-5 days Newbie friendliness 52/100
Maintainers usually reply within 1 day
Similar issues
-
raised-by:reviewer
Difficulty 1/5 1-3 hours Newbie friendliness 84/100
medici-finance/assay#2681 ·
Maintainers usually reply within 1 day
-
Difficulty 1/5 Under an hour Newbie friendliness 82/100
homarr-labs/dashboard-icons#2939 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 66/100
AvengeMedia/DankMaterialShell#3761 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 77/100
Maintainers usually reply within 1 day
-
docs: clarify replacement for outdated Terms of Service URL in readme.mdPossibly taken @mohitjoshi-hey claimed this today. Open
Difficulty 1/5 1-3 hours Newbie friendliness 66/100