Skip to content

mcp: LoggingTransport drops session state updates, so a wrapped transport behaves differently #1328

Description

@Zhuoxi2000

Describe the bug

loggingConn (mcp/transport.go) implements only the Connection methods. Client.Connect (client.go:333, :402) and ServerSession.updateState (server.go:1634) type-assert the unexported clientConnection / serverConnection interfaces on the connection and silently skip the sessionUpdated call when the assertion fails. So wrapping a transport in LoggingTransport changes its protocol behaviour, not just its logging:

  • Client, LoggingTransport{Transport: &StreamableClientTransport{...}}: streamableClientConn.initializedResult is never set. For sessions below 2026-07-28, no request after initialize carries Mcp-Protocol-Version, and connectStandaloneSSE never runs, so there is no standalone GET and the client never receives server-initiated messages sent outside a request.
  • Server, LoggingTransport{Transport: &StdioTransport{}} (the pattern used by examples/server/{everything,memory,sequentialthinking}): ioConn never learns the negotiated version, so a JSON-RPC batch is answered on a 2025-11-25 session. Without the wrapper it is rejected (see TestIOConnRead "batching new protocol").

This is the same root cause as #1109. #1107 fixed the header for 2026-07-28 and later sessions by reading _meta.protocolVersion from the outgoing message. That does not cover older negotiated versions, the standalone SSE stream, or the server side, which all still depend on the hook. TestStreamableClientConnSetMCPHeaders_ProtocolVersion describes the same failed assertion.

To Reproduce

Against main @ 9ceba26:

ctx := context.Background()
server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v1"}, nil)
handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, nil)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	fmt.Printf("  %-6s Mcp-Protocol-Version=%q\n", r.Method, r.Header.Get("Mcp-Protocol-Version"))
	handler.ServeHTTP(w, r)
}))
defer ts.Close()

for _, wrap := range []bool{false, true} {
	fmt.Printf("LoggingTransport=%v\n", wrap)
	var t mcp.Transport = &mcp.StreamableClientTransport{Endpoint: ts.URL}
	if wrap {
		t = &mcp.LoggingTransport{Transport: t, Writer: io.Discard}
	}
	client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v1"}, nil)
	cs, err := client.Connect(ctx, t, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"})
	if err != nil {
		log.Fatal(err)
	}
	cs.Ping(ctx, nil)
	time.Sleep(200 * time.Millisecond)
	cs.Close()
	time.Sleep(50 * time.Millisecond)
}

Output:

LoggingTransport=false
  POST   Mcp-Protocol-Version=""
  GET    Mcp-Protocol-Version="2025-11-25"
  POST   Mcp-Protocol-Version="2025-11-25"
  POST   Mcp-Protocol-Version="2025-11-25"
  DELETE Mcp-Protocol-Version="2025-11-25"
LoggingTransport=true
  POST   Mcp-Protocol-Version=""
  POST   Mcp-Protocol-Version=""
  POST   Mcp-Protocol-Version=""
  DELETE Mcp-Protocol-Version=""

For the server side: connect a Server over LoggingTransport{Transport: &IOTransport{...}}, initialize at 2025-11-25, send notifications/initialized, then send [{"jsonrpc":"2.0","id":2,"method":"ping"},{"jsonrpc":"2.0","id":3,"method":"ping"}]. The wrapped server replies [{"jsonrpc":"2.0","id":2,"result":{}},{"jsonrpc":"2.0","id":3,"result":{}}]. The unwrapped one rejects the batch.

Expected behavior

LoggingTransport is documented as "a Transport that delegates to another transport, writing RPC logs", and docs/troubleshooting.md recommends it for inspecting traffic. Wrapping a transport should only add logging. In particular, the client MUST send MCP-Protocol-Version on all requests after initialization (transports spec 2025-06-18, Protocol Version Header), and batching was removed in 2025-06-18.

Additional context

The two hooks share the method name sessionUpdated but take different parameter types, so one wrapper type cannot implement both. A small fix, with no exported API change:

  • rename the unexported methods to clientSessionUpdated / serverSessionUpdated and update the three call sites;
  • have loggingConn forward them to the delegate when the delegate implements them. propagateCancellation (looked up the same way in connect, implemented by streamableServerConn) gets the same forwarder.

Open PRs #1267 (DropResponse) and #1146 (cancelsListenWithContext) each add a forwarder to loggingConn in the same place, so these changes will touch neighbouring lines.

I have this fix ready, with regression tests that fail on main and pass with it. I can send it as a PR if this approach works for you.

Environment: go-sdk main @ 9ceba26, go1.27.1 darwin/arm64.


AI assistance: drafted with an AI coding assistant (Claude). The reproduction above was run locally against the current default branch.

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