Repository navigation
mcp: LoggingTransport drops session state updates, so a wrapped transport behaves differently #1328
Description
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.initializedResultis never set. For sessions below 2026-07-28, no request afterinitializecarriesMcp-Protocol-Version, andconnectStandaloneSSEnever 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}):ioConnnever learns the negotiated version, so a JSON-RPC batch is answered on a 2025-11-25 session. Without the wrapper it is rejected (seeTestIOConnRead"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/serverSessionUpdatedand update the three call sites; - have
loggingConnforward them to the delegate when the delegate implements them.propagateCancellation(looked up the same way inconnect, implemented bystreamableServerConn) 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
Zhuoxi2000 commentedon Oct 2, 2026
I'd like to take this one myself and open the PR. I have a fix that makes LoggingTransport pass session state updates through to the wrapped transport, plus a regression test, ready locally. Per CONTRIBUTING, I'll wait for a maintainer to confirm before sending it, and I'm happy to adjust the approach if you prefer a different direction.
AI assistance: drafted with an AI coding assistant (Claude).