Skip to content

SSEClientTransport drops path prefix when endpoint URL is not on the HTTP root (similar to python-sdk#563) #687

Closed

Description

@zyfxgo

Describe the bug
SSEClientTransport drops the path prefix of the configured Endpoint when resolving the message endpoint from the first endpoint SSE event.

In Connect, the message endpoint is derived like this:

// Connect connects through the client endpoint.
func (c *SSEClientTransport) Connect(ctx context.Context) (Connection, error) {
    parsedURL, err := url.Parse(c.Endpoint)
    if err != nil {
        return nil, fmt.Errorf("invalid endpoint: %v", err)
    }
    req, err := http.NewRequestWithContext(ctx, "GET", c.Endpoint, nil)
    if err != nil {
        return nil, err
    }
    httpClient := c.HTTPClient
    if httpClient == nil {
        httpClient = http.DefaultClient
    }
    req.Header.Set("Accept", "text/event-stream")
    resp, err := httpClient.Do(req)
    if err != nil {
        return nil, err
    }

    msgEndpoint, err := func() (*url.URL, error) {
        var evt Event
        for evt, err = range scanEvents(resp.Body) {
            break
        }
        if err != nil {
            return nil, err
        }
        if evt.Name != "endpoint" {
            return nil, fmt.Errorf("first event is %q, want %q", evt.Name, "endpoint")
        }
        raw := string(evt.Data)
        return parsedURL.Parse(raw)
    }()
    if err != nil {
        resp.Body.Close()
        return nil, fmt.Errorf("missing endpoint: %v", err)
    }

    // From here on, the stream takes ownership of resp.Body.
    s := &sseClientConn{
        client:      httpClient,
        msgEndpoint: msgEndpoint,
        incoming:    make(chan []byte, 100),
        body:        resp.Body,
        done:        make(chan struct{}),
    }

    go func() {
        defer s.Close() // close the transport when the GET exits

        for evt, err := range scanEvents(resp.Body) {
            if err != nil {
                return
            }
            select {
            case s.incoming <- evt.Data:
            case <-s.done:
                return
            }
        }
    }()

    return s, nil
}

When the server sends an endpoint event whose data starts with / (for example /messages?sessionId=123), parsedURL.Parse(raw) replaces the entire path of c.Endpoint instead of preserving the prefix.

So if c.Endpoint is:

http://192.168.1.57:8000/mcp/http://192.168.1.30:8088/sse

and the server sends:

event: endpoint
data: /messages?sessionId=123

then msgEndpoint becomes:

http://192.168.1.57:8000/messages?sessionId=123

instead of:

http://192.168.1.57:8000/mcp/http://192.168.1.30:8088/messages?sessionId=123

This makes it impossible to use SSEClientTransport with an MCP server that is mounted behind a path prefix or with a reverse proxy that encodes routing information in the path.

This is very similar to the issue reported in the Python SDK in modelcontextprotocol/python-sdk#563.


To Reproduce
Steps to reproduce the behavior:

  1. Put an MCP SSE server behind a reverse proxy that exposes it at a non-root path, for example:

    http://192.168.1.57:8000/mcp/http://192.168.1.30:8088/sse
    
  2. Have the backend server send an endpoint event like:

    event: endpoint
    data: /messages?sessionId=123
    
  3. In a Go client, connect using SSEClientTransport:

    transport := &mcp.SSEClientTransport{
        Endpoint: "http://192.168.1.57:8000/mcp/http://192.168.1.30:8088/sse",
    }
    
    client := mcp.NewClient(&mcp.Implementation{
        Name:    "mcp-client",
        Version: "dev",
    }, nil)
    
    ctx := context.Background()
    session, err := client.Connect(ctx, transport, nil)
    if err != nil {
        log.Fatalf("Connect failed: %v", err)
    }
    defer session.Close()
  4. Check the HTTP traffic on 192.168.1.57:8000 (e.g. reverse proxy logs). You’ll see:

    • The initial GET goes to /mcp/http://192.168.1.30:8088/sse (correct).
    • Subsequent POSTs go to /messages?sessionId=123 (missing the /mcp/http://192.168.1.30:8088 prefix), which breaks any path-based routing.

Expected behavior
The message endpoint resolution should not silently drop the original path prefix of c.Endpoint when the server sends the endpoint event.

For example, for:

  • Endpoint = "http://192.168.1.57:8000/mcp/http://192.168.1.30:8088/sse"
  • endpoint event data /messages?sessionId=123

I would expect msgEndpoint to still include the prefix (or at least have a way to preserve it), so that path-based reverse proxies remain usable.


Logs
Example reverse-proxy logs (simplified):

# Initial SSE connection
192.168.1.100 - - "GET /mcp/http://192.168.1.30:8088/sse HTTP/1.1" 200 -

# Subsequent POST from go-sdk SSE client (missing prefix)
192.168.1.100 - - "POST /messages?sessionId=123 HTTP/1.1" 404 -

The proxy only routes /mcp/http://192.168.1.30:8088/..., so the POST to /messages fails.


Additional context
Two questions / possible directions where I’d really appreciate guidance from the maintainers:

  1. Can / should servers return a “relative” path in the endpoint event?
    Right now many examples use a leading slash, e.g. data: /messages?sessionId=123.
    If instead the server returned something like data: messages?sessionId=123 (no leading /), then parsedURL.Parse(raw) would treat it as relative to the original endpoint path and the prefix would be preserved.

    • Is such a relative URI considered valid according to the MCP spec for endpoint events?
    • If yes, should server implementations be encouraged to do this when they expect to live behind path prefixes?
  2. Would it make sense for the Go client to support configuring two paths / URLs?
    For example:

    • One URL for the initial SSE GET (Endpoint), and
    • Another explicit URL / base URL for the message POST endpoint (e.g. MessageEndpoint or similar), instead of always deriving it from Endpoint + evt.Data.
      This would make it much easier to support path-based reverse proxies (and setups where routing information is encoded in the path) without having to modify the MCP server itself.

Current workaround

As a temporary workaround, I’m using a rather ugly “double-prefixed” endpoint and a very permissive server handler:

  • Client-side Endpoint configured in SSEClientTransport:

    http://192.168.1.57:8000/mcp/http://192.168.1.30:8088/mcp/http://192.168.1.30:8088/
    
  • After the reverse proxy on 192.168.1.57:8000, this is forwarded to the backend as:

    http://192.168.1.30:8088/mcp/http://192.168.1.30:8088/
    
  • The MCP server (which also uses go-sdk) sends an endpoint event like:

    event: endpoint
    data: /mcp/http://192.168.1.30:8088/?sessionid=xxx
    
  • After the same proxy logic, the final POSTs end up at:

    http://192.168.1.30:8088/?sessionid=xxx
    

On the server side I’m using the go-sdk SSE handler mounted at the root, without any path restrictions:

sseHandler := mcp.NewSSEHandler(s, nil)
err := http.ListenAndServe(address, sseHandler)
if !errors.Is(err, http.ErrServerClosed) {
    slog.Error("failed to listen and serve", "error", err)
    os.Exit(1)
}

Because NewSSEHandler is handling all incoming paths and only cares about the query parameter sessionid, this setup happens to work and the session ID is parsed correctly. However, it’s clearly a hacky workaround and illustrates how difficult it is to use SSEClientTransport cleanly when the server is behind a path-based reverse proxy.

  • go-sdk version: v1.1.0
  • Go version: 1.25.4
  • OS: macOS

Activity

  1. findleyr commented on Jan 7, 2026

    @findleyr
    Contributor

    FWIW it's not entirely clear to me that the correct fix is client-side. If I understand modelcontextprotocol/python-sdk#540 correctly, the fix was server-side.

    In general, it looks like the current client behavior is correct (c.f. https://developer.mozilla.org/en-US/docs/Web/API/URL_API/Resolving_relative_references#root_relative).

  2. added
    needs investigationStatus unclear, requires more work and discussion
    and removed on Feb 13, 2026
  3. IT-HONGREAT commented on Feb 24, 2026

    @IT-HONGREAT

    Hi!!👋
    I looked into this and the python-sdk fix (PR #540) was server-side.
    I think the key change was in SseServerTransport.connect_sse()

    root_path = scope.get("root_path", "")
    full_message_path_for_client = root_path.rstrip("/") + self._endpoint
    client_post_uri_data = f"{quote(full_message_path_for_client)}?session_id={session_id.hex}"

    The ASGI scope["root_path"] tells the handler where it's mounted (e.g "/api_prefix"), so the endpoint event includes the fullpath the client should to.

    In the Go SDK, SSEHandler.ServeHTTP currently does

    endpoint, err := req.URL.Parse("?sessionid=" + sessionID)

    This works when the server is at the root, but req.URL has already had its prefix stripped by a reverse proxy, so the endpoint event is missing the prefix.

    Go's net/http doesn't have an equivalent of ASGI's root_path, so a direct port wouldn't work. A possible direction could be adding a BasePath option to SSEOptions, or reading a header like X-Forwarded-Prefix -- but I'd like to hear your thoughts on the preferred approach before going further.

    Happy to work on this if the direction makes sense.

  4. zyfxgo commented on Feb 25, 2026

    @zyfxgo
    Author

    Thanks for the thoughtful discussion — I’d like to clarify my position more precisely.

    My concern is not specific to Go or to the lack of an ASGI-style root_path. Even in Python, I don’t think relying on framework-provided mount context to reconstruct externally visible paths is an ideal long-term direction.

    The core issue is that endpoint resolution should not depend on deployment topology. When a server reconstructs its externally visible prefix (via root_path, reverse-proxy headers, or configuration), the correctness of the protocol becomes coupled to proxy behavior and framework-specific metadata. That reduces portability and makes behavior dependent on infrastructure details.

    From a protocol perspective, the most robust approach would be:

    • The endpoint event should use a relative reference (e.g. messages?sessionId=..., without a leading /).
    • This makes the meaning unambiguous: it is relative to the already-established SSE endpoint URL.
    • No external mount knowledge, no proxy headers, and no prefix reconstruction are required.

    However, since some existing servers already emit root-relative paths like /messages?..., I think the Go SDK could optionally support an explicit MessageBase / BasePath (or similar) configuration. In reverse-proxy scenarios, the client/operator is the component that actually knows the externally visible URL structure, so making this explicit is reasonable.

    In short:

    • I don’t think server-side prefix reconstruction (via root_path or headers) should be the primary solution.
    • I’d prefer encouraging relative references in the protocol.
    • And optionally supporting explicit base configuration on the client side for proxy deployments.

    I’m happy to help implement whichever direction aligns best with the spec’s intent.

  5. guglielmo-san commented on Oct 27, 2026

    @guglielmo-san
    Contributor

    Closing as outdated

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

    needs investigationStatus unclear, requires more work and discussion

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions