labring / labring/aiproxy

MCP SSE /message can return 202 even when the response is later dropped by a full SSE event queue

Open
#641 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Go
Stars
543
Forks
112
Avg merge
3h 11m
Merged PRs (30d)
20

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:

1. The routes are split between the SSE connection and the message endpoint.

[`core/router/mcp.go#L22-L23`](https://github.com/labring/aiproxy/blob/4963684929bfbb9071aea4400fd75593336bf7a7/core/router/mcp.go#L22-L23):

```go
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.

2. When the SSE connection is opened, a session and an `SSEServer` are created:

[`core/controller/mcp/mcp.go#L30-L52`](https://github.com/labring/aiproxy/blob/4963684929bfbb9071aea4400fd75593336bf7a7/core/controller/mcp/mcp.go#L30-L52):

```go
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.

3. The SSE server has a bounded event queue:

[`core/mcpproxy/sse.go#L49-L57`](https://github.com/labring/aiproxy/blob/4963684929bfbb9071aea4400fd75593336bf7a7/core/mcpproxy/sse.go#L49-L57):

```go
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`](https://github.com/labring/aiproxy/blob/4963684929bfbb9071aea4400fd75593336bf7a7/core/mcpproxy/sse.go#L123-L132):

```go
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.

4. `POST /message` only confirms that the request was put into the request-side MPSC queue:

[`core/controller/mcp/mcp.go#L115-L136`](https://github.com/labring/aiproxy/blob/4963684929bfbb9071aea4400fd75593336bf7a7/core/controller/mcp/mcp.go#L115-L136):

```go
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`](https://github.com/labring/aiproxy/blob/4963684929bfbb9071aea4400fd75593336bf7a7/core/controller/mcp/mcp-mpsc.go#L126-L136):

```go
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.

5. The background goroutine then processes the request and tries to enqueue the response:

[`core/controller/mcp/mcp.go#L61-L75`](https://github.com/labring/aiproxy/blob/4963684929bfbb9071aea4400fd75593336bf7a7/core/controller/mcp/mcp.go#L61-L75):

```go
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`](https://github.com/labring/aiproxy/blob/4963684929bfbb9071aea4400fd75593336bf7a7/core/mcpproxy/sse.go#L138-L160):

```go
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.

6. That response-side enqueue error is currently swallowed:

[`core/controller/mcp/mcp.go#L72-L74`](https://github.com/labring/aiproxy/blob/4963684929bfbb9071aea4400fd75593336bf7a7/core/controller/mcp/mcp.go#L72-L74):

```go
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:

- [`core/controller/mcp/embedmcp.go#L450-L471`](https://github.com/labring/aiproxy/blob/4963684929bfbb9071aea4400fd75593336bf7a7/core/controller/mcp/embedmcp.go#L450-L471)

## 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:

1. Open an MCP SSE session.
2. Make the SSE reader slow or blocked so `eventQueue` is not drained quickly.
3. POST more than 100 JSON-RPC requests to `/message?sessionId=...`.
4. 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.

Contributor guide

No contributing guide indexed for this repository

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.

Assessment

Tech stack
go
Domain
api, backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
58/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.