Skip to content

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

Description

@chenshj73

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:

    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:

    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:

    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.

  4. 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.

  5. 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.

  6. 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:

  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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions