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.
Describe the bug
loggingConn(mcp/transport.go) implements only theConnectionmethods.Client.Connect(client.go:333, :402) andServerSession.updateState(server.go:1634) type-assert the unexportedclientConnection/serverConnectioninterfaces on the connection and silently skip thesessionUpdatedcall when the assertion fails. So wrapping a transport inLoggingTransportchanges its protocol behaviour, not just its logging: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.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.protocolVersionfrom 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_ProtocolVersiondescribes the same failed assertion.To Reproduce
Against main @ 9ceba26:
Output:
For the server side: connect a
ServeroverLoggingTransport{Transport: &IOTransport{...}}, initialize at 2025-11-25, sendnotifications/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
LoggingTransportis 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 sendMCP-Protocol-Versionon 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
sessionUpdatedbut take different parameter types, so one wrapper type cannot implement both. A small fix, with no exported API change:clientSessionUpdated/serverSessionUpdatedand update the three call sites;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 tologgingConnin 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.