Skip to content

Browser-based MCP clients cannot connect due to missing OPTIONS/CORS and notification response handling #197

@bettercallsaulj

Description

@bettercallsaulj

1. What's the Issue

Browser-based MCP clients (such as MCP Inspector) fail to connect to gopher-mcp servers due to two related problems:

Problem 1: OPTIONS Preflight Requests Not Handled

When a browser-based client attempts to make a cross-origin request to the MCP server, the browser first sends an OPTIONS preflight request to check if the server allows the request. The gopher-mcp server did not handle OPTIONS requests, causing the preflight to timeout or fail.

Error observed:

Access to fetch at 'http://localhost:3001/mcp' from origin 'http://localhost:6274'
has been blocked by CORS policy

Problem 2: JSON-RPC Notifications Hang Forever

After successful initialization, the client sends a notifications/initialized JSON-RPC notification. In JSON-RPC 2.0, notifications don't receive a response. However, over HTTP transport, the server must still send an HTTP response (even if the body is empty). The server was not sending any HTTP response for notifications, causing the connection to hang.

Error observed:

Request timed out

The notifications/initialized request would hang indefinitely waiting for a response that never came.

2. How to Reproduce

Prerequisites

  • Build gopher-mcp with HTTP/SSE transport enabled
  • Run an MCP server using HTTP/SSE transport

Steps to Reproduce Problem 1 (OPTIONS)

  1. Start the MCP server:

    ./mcp_example_server  # or any gopher-mcp server with HTTP/SSE transport
  2. Send an OPTIONS preflight request:

    curl -v -X OPTIONS http://localhost:3001/mcp \
      -H "Origin: http://localhost:5173" \
      -H "Access-Control-Request-Method: POST" \
      -H "Access-Control-Request-Headers: Content-Type"
  3. Expected: HTTP 204 No Content with CORS headers

  4. Actual (before fix): Request times out or returns error

Steps to Reproduce Problem 2 (Notification Hang)

  1. Start the MCP server:

    ./mcp_example_server  # or any gopher-mcp server with HTTP/SSE transport
  2. Send an initialize request (this works):

    curl -s -X POST http://localhost:3001/mcp \
      -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}'
  3. Send a notification (this hangs):

    curl -s --max-time 5 -X POST http://localhost:3001/mcp \
      -H "Content-Type: application/json" \
      -d '{"method":"notifications/initialized","jsonrpc":"2.0"}'
  4. Expected: HTTP 202 Accepted with empty body

  5. Actual (before fix): Request hangs until timeout

Using MCP Inspector

  1. Open MCP Inspector at http://localhost:6274
  2. Select "Streamable HTTP" transport
  3. Enter URL: http://localhost:3001/mcp
  4. Click Connect
  5. Before fix: Connection fails with timeout error

3. How to Fix It

The fix involves two changes in src/filter/http_sse_filter_chain_factory.cc:

Fix 1: Add OPTIONS Preflight Handlers

Register OPTIONS handlers for all MCP-related paths:

void setupRoutingHandlers() {
  // Register CORS preflight handler for all paths
  auto corsHandler = [](const HttpRoutingFilter::RequestContext& req) {
    HttpRoutingFilter::Response resp;
    resp.status_code = 204;  // No Content
    resp.headers["Access-Control-Allow-Origin"] = "*";
    resp.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS";
    resp.headers["Access-Control-Allow-Headers"] =
        "Content-Type, Authorization, Accept, Mcp-Session-Id, Mcp-Protocol-Version";
    resp.headers["Access-Control-Max-Age"] = "86400";  // Cache for 24 hours
    resp.headers["Content-Length"] = "0";
    return resp;
  };

  // Register OPTIONS for common MCP paths
  routing_filter_->registerHandler("OPTIONS", "/mcp", corsHandler);
  routing_filter_->registerHandler("OPTIONS", "/mcp/events", corsHandler);
  routing_filter_->registerHandler("OPTIONS", "/rpc", corsHandler);
  routing_filter_->registerHandler("OPTIONS", "/health", corsHandler);
  routing_filter_->registerHandler("OPTIONS", "/info", corsHandler);

  // Also handle OPTIONS in default handler for any unregistered path
  routing_filter_->registerDefaultHandler(
      [](const HttpRoutingFilter::RequestContext& req) {
        if (req.method == "OPTIONS") {
          // Return 204 with CORS headers
          ...
        }
        // Pass through other methods
        ...
      });
}

Fix 2: Send HTTP 202 for Notifications

In the onNotification handler, send an HTTP response:

void onNotification(const jsonrpc::Notification& notification) override {
  mcp_callbacks_.onNotification(notification);

  // For HTTP transport, send HTTP 202 Accepted response
  // JSON-RPC notifications don't have responses, but HTTP requires one
  if (is_server_ && write_callbacks_) {
    std::string http_response =
        "HTTP/1.1 202 Accepted\r\n"
        "Content-Length: 0\r\n"
        "Access-Control-Allow-Origin: *\r\n"
        "Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n"
        "Access-Control-Allow-Headers: Content-Type, Authorization, Accept, "
        "Mcp-Session-Id, Mcp-Protocol-Version\r\n"
        "Connection: keep-alive\r\n"
        "\r\n";

    OwnedBuffer response_buffer;
    response_buffer.add(http_response);
    write_callbacks_->connection().write(response_buffer, false);
  }
}

Verification

After the fix:

# OPTIONS now returns 204 with CORS headers
$ curl -s -i -X OPTIONS http://localhost:3001/mcp
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, Accept, Mcp-Session-Id, Mcp-Protocol-Version
Access-Control-Max-Age: 86400
Content-Length: 0

# Notifications now return 202 immediately
$ curl -s -i -X POST http://localhost:3001/mcp \
    -H "Content-Type: application/json" \
    -d '{"method":"notifications/initialized","jsonrpc":"2.0"}'
HTTP/1.1 202 Accepted
Content-Length: 0
Access-Control-Allow-Origin: *
...

MCP Inspector can now successfully connect and interact with the server.

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