Skip to content

Conversation

havasik
Copy link

@havasik havasik commented Sep 11, 2025

Description

Fixes #576

Support for handling https://modelcontextprotocol.io/specification/2025-03-26/basic/utilities/ping has been added for Streamable HTTP client, when clients are created with transport.WithContinuousListening().

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • MCP spec compatibility implementation
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Code refactoring (no functional changes)
  • Performance improvement
  • Tests only (no functional changes)
  • Other (please describe):

Checklist

  • My code follows the code style of this project
  • I have performed a self-review of my own code
  • I have added tests that prove my fix is effective or that my feature works
  • I have updated the documentation accordingly

MCP Spec Compliance

  • This PR implements a feature defined in the MCP specification
  • Link to relevant spec section: Link text
  • Implementation follows the specification exactly

Additional Information

Summary by CodeRabbit

  • New Features

    • Server-to-client ping handling and client-side ping acknowledgments for more responsive connections.
    • SSE-compatible responses accepted to support event-stream payloads.
  • Refactor

    • Replaced fixed per-request deadlines with cancellation-driven contexts for faster cancellation and improved retry behavior.
  • Bug Fixes

    • Handle SSE events that carry only data (no event field).
  • Tests

    • Added/extended tests for continuous listening, periodic pings/acknowledgments, SSE edge cases, and improved test synchronization.

Copy link
Contributor

coderabbitai bot commented Sep 11, 2025

Walkthrough

Adds server-to-client JSON-RPC "ping" handling in the client, replaces per-request timeouts with cancelable contexts in the Streamable HTTP transport, broadens the transport Accept header to include SSE, and extends Streamable HTTP tests to exercise ping acknowledgements, periodic server pings, and SSE events lacking an event field.

Changes

Cohort / File(s) Summary
Client request handling
client/client.go
Route incoming JSON-RPC "ping" requests to a new handlePingRequest(ctx, request) that returns a JSON-RPC response with an empty {} Result; update comment to mention ping handling.
Streamable HTTP transport
client/transport/streamable_http.go
Replace per-attempt timeouts (context.WithTimeout) with cancelable contexts (context.WithCancel) in handleSSEResponse and listenForever; remove timeout cancellation logic; expand Accept header in sendResponseToServer to application/json, text/event-stream.
Streamable HTTP tests
client/transport/streamable_http_test.go
Mock server: acknowledge POSTs containing a result by echoing id with HTTP 202; extend GET/SSE path to emit periodic JSON-RPC "ping" messages; update tests to ACK pings and assert at least one ping received in TestContinuousListening; add SSEEventWithoutEventField test; return additional sync channels from startMockStreamableWithGETSupport.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Possibly related PRs

Suggested labels

area: mcp spec

Suggested reviewers

  • dugenkui03
  • rwjblue-glean
  • pottekkat

Pre-merge checks (3 passed, 2 warnings)

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Linked Issues Check ⚠️ Warning Linked issue #576 requests a proactive, client-initiated keepalive ping mechanism to maintain long-running HTTP sessions, but the changes in this PR implement handling and acknowledgement of server-initiated pings and test scaffolding for server-sent pings instead. The code does not add a client-side periodic ping sender, so the primary coding objective from #576 (proactive client-initiated keepalive pings) is not satisfied by this PR. To satisfy #576, implement a client-side periodic ping sender (e.g., enabled by transport.WithContinuousListening()) that issues JSON-RPC "ping" requests during long-running operations without breaking MCP request sequencing, add unit/integration tests that simulate server timeouts and long tasks, and update the PR description and documentation to reference the new proactive keepalive behavior.
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (3 passed)
Check name Status Explanation
Title Check ✅ Passed The current title "fix: Streamable HTTP client ping response handling" succinctly and accurately summarizes the primary change: adjustments to how the Streamable HTTP client handles ping messages. It names the affected component and the core behavior change so a reviewer scanning history will understand the main intent.
Out of Scope Changes Check ✅ Passed All modified files and test changes appear directly related to enabling ping handling and continuous listening (adding server ping handling, SSE compatibility, and test coverage for ping/SSE edge cases); I did not find unrelated feature work. Reviewers should, however, verify the changed cancellation behavior in listenForever (the context cancel invocation timing) to ensure it is intentional rather than an accidental change.
Description Check ✅ Passed The PR description follows the repository template and includes "Fixes #576", a clear summary, the MCP-spec checkbox selection, checklist entries for style/self-review/tests, and a link to the spec section, making it largely complete for reviewers. The description does not mark a documentation update, which is a minor omission but not critical for understanding the change.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@havasik havasik changed the title feat: Streamable HTTP client ping response handling fix: Streamable HTTP client ping response handling Sep 11, 2025
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
client/transport/streamable_http.go (1)

623-623: Consider implementing exponential backoff for retry logic.

The immediate retry approach could lead to excessive resource consumption and server load if the connection repeatedly fails. Consider implementing exponential backoff with jitter to be more considerate of server resources and network conditions.

Example implementation:

retryDelay := time.Second
maxRetryDelay := 30 * time.Second

for {
    // ... existing connection attempt code ...
    
    if err != nil {
        c.logger.Errorf("failed to listen to server, retrying after %v: %v", retryDelay, err)
        
        // Wait with backoff
        select {
        case <-time.After(retryDelay):
            // Exponential backoff with cap
            retryDelay = retryDelay * 2
            if retryDelay > maxRetryDelay {
                retryDelay = maxRetryDelay
            }
        case <-ctx.Done():
            return
        }
    } else {
        // Reset delay on successful connection
        retryDelay = time.Second
    }
}
client/transport/streamable_http_test.go (1)

774-781: Consider making the ping timeout configurable or more robust.

The test waits up to 5 seconds for a ping that should arrive every 3 seconds. While this provides some buffer, consider:

  1. Making the timeout relative to the ping interval (e.g., pingInterval * 2)
  2. Waiting for multiple pings to ensure the mechanism works consistently
+	const pingInterval = 3 * time.Second
+	const pingTimeout = pingInterval * 2
+	
 	// Wait for at least one ping to be received (should happen within 3 seconds)
 	select {
 	case <-pingReceived:
 		t.Log("Received ping message successfully")
 		time.Sleep(10 * time.Millisecond) // Allow time for response
-	case <-time.After(5 * time.Second):
-		t.Errorf("Expected to receive ping message within 5 seconds, but didn't")
+	case <-time.After(pingTimeout):
+		t.Errorf("Expected to receive ping message within %v, but didn't", pingTimeout)
 	}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2300366 and 7b47212.

📒 Files selected for processing (3)
  • client/client.go (2 hunks)
  • client/transport/streamable_http.go (2 hunks)
  • client/transport/streamable_http_test.go (4 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
client/client.go (2)
mcp/types.go (5)
  • JSONRPCRequest (325-330)
  • JSONRPCResponse (339-343)
  • MethodPing (25-25)
  • JSONRPC_VERSION (111-111)
  • Result (246-250)
client/transport/interface.go (2)
  • JSONRPCRequest (57-62)
  • JSONRPCResponse (64-73)
client/transport/streamable_http_test.go (2)
mcp/types.go (3)
  • JSONRPCRequest (325-330)
  • JSONRPCResponse (339-343)
  • Result (246-250)
client/transport/interface.go (2)
  • JSONRPCRequest (57-62)
  • JSONRPCResponse (64-73)
🔇 Additional comments (9)
client/client.go (3)

461-461: LGTM! Clear and accurate comment update.

The comment appropriately documents that this handler now processes both sampling and ping requests from the server.


466-467: LGTM! Correct ping request routing.

The ping request is properly routed to the dedicated handler.


521-530: No validation required — MCP ping has no parameters.
MCP "ping" is a JSON-RPC request with an empty params object; returning an empty JSON result ({}) is correct and no additional request-parameter validation is needed.

Likely an incorrect or invalid review comment.

client/transport/streamable_http.go (2)

615-621: LGTM! Proper defensive check before retry.

Good addition of the context cancellation check before retrying. This prevents unnecessary retry attempts when the client is shutting down.


768-768: LGTM! Proper Accept header for SSE support.

The extended Accept header correctly indicates support for both JSON and SSE responses, which is appropriate for handling ping acknowledgments that may come via SSE.

client/transport/streamable_http_test.go (4)

557-570: LGTM! Proper ping acknowledgment handling.

The implementation correctly handles ping responses from the client by checking for a result field and responding with HTTP 202 Accepted status, which aligns with the MCP specification for acknowledging pings.


647-669: LGTM! Well-implemented keepalive mechanism.

The periodic ping implementation correctly:

  • Uses a 3-second interval for keepalive pings
  • Generates unique ping IDs
  • Follows the JSON-RPC 2.0 format
  • Properly cleans up the ticker on exit

709-724: LGTM! Comprehensive ping handling in tests.

The test properly validates the bidirectional ping flow:

  1. Receives ping requests from the server
  2. Responds with the correct JSON-RPC format
  3. Uses an empty JSON object as the result, matching the MCP spec

418-492: LGTM! Good test coverage for SSE edge case.

The test properly validates that SSE events without an explicit event: field are processed as "message" events by default, which is correct according to the SSE specification. This ensures compatibility with servers that may omit the event field.

@havasik havasik force-pushed the streamable-handle-ping branch from 7b47212 to 6ea7d8c Compare September 11, 2025 09:40
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7b47212 and 6ea7d8c.

📒 Files selected for processing (3)
  • client/client.go (2 hunks)
  • client/transport/streamable_http.go (2 hunks)
  • client/transport/streamable_http_test.go (4 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • client/client.go
  • client/transport/streamable_http_test.go
🔇 Additional comments (2)
client/transport/streamable_http.go (2)

615-621: Pre-retry cancellation check: LGTM

Good guard to avoid pointless reconnect attempts during shutdown.


769-780: Accept header for POSTed responses: spec-compliant

Including both application/json and text/event-stream on all client POSTs (requests, notifications, responses) matches the Streamable HTTP spec. Servers that receive only responses/notifications should reply 202 with no body, which your code already handles. (spec.modelcontextprotocol.io)

@havasik havasik force-pushed the streamable-handle-ping branch from 6ea7d8c to 68e8505 Compare September 11, 2025 10:23
@havasik havasik force-pushed the streamable-handle-ping branch from 0309bb8 to a97bd22 Compare September 11, 2025 12:40
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (3)
client/transport/streamable_http_test.go (3)

557-571: Tighten JSON-RPC response detection to avoid false positives

Gate on absence of "method" and presence of "id" when treating POSTs as responses. Prevents misclassifying regular requests with a stray "result" field.

-			// Handle ping responses from client
-			if _, hasResult := request["result"]; hasResult {
-				w.Header().Set("Content-Type", "application/json")
-				w.WriteHeader(http.StatusAccepted)
-				if err := json.NewEncoder(w).Encode(map[string]any{
-					"jsonrpc": "2.0",
-					"id":      request["id"],
-					"result":  "response received",
-				}); err != nil {
-					http.Error(w, "Failed to encode response acknowledgment", http.StatusInternalServerError)
-					return
-				}
-				return
-			}
+			// Handle client JSON-RPC responses (e.g., to server-initiated ping)
+			if _, hasResult := request["result"]; hasResult {
+				if _, hasMethod := request["method"]; hasMethod {
+					// Not a response; fall through to request handling.
+				} else {
+					if _, hasID := request["id"]; !hasID {
+						http.Error(w, "Missing id in JSON-RPC response", http.StatusBadRequest)
+						return
+					}
+					w.Header().Set("Content-Type", "application/json")
+					w.WriteHeader(http.StatusAccepted)
+					if err := json.NewEncoder(w).Encode(map[string]any{
+						"jsonrpc": "2.0",
+						"id":      request["id"],
+						"result":  "response received",
+					}); err != nil {
+						http.Error(w, "Failed to encode response acknowledgment", http.StatusInternalServerError)
+						return
+					}
+					return
+				}
+			}

709-725: Limit handler scope to ping and avoid emitting errors for unknown methods

Return nil for non-ping so future server-initiated methods (e.g., sampling) don’t fail this test. Also fix the misleading comment.

-	// Setup notification handler
+	// Setup request handler for ping
 	pingReceived := make(chan struct{}, 10)

 	// Setup request handler for ping requests
 	trans.SetRequestHandler(func(ctx context.Context, request JSONRPCRequest) (*JSONRPCResponse, error) {
 		if request.Method == "ping" {
 			pingReceived <- struct{}{}
 			// Return proper ping response according to MCP specification
 			return &JSONRPCResponse{
 				JSONRPC: "2.0",
 				ID:      request.ID,
 				Result:  json.RawMessage("{}"),
 			}, nil
 		}
-		return nil, fmt.Errorf("unsupported request method: %s", request.Method)
+		// Ignore other methods in this test.
+		return nil, nil
 	})

774-781: Remove unnecessary sleep; optionally extend timeout if keeping 3s ticks

The post-receipt sleep adds no value and can slow tests; if the 3s ticker is retained, consider a slightly larger timeout.

 	case <-pingReceived:
 		t.Log("Received ping message successfully")
-		time.Sleep(10 * time.Millisecond) // Allow time for response
 	case <-time.After(5 * time.Second):
 		t.Errorf("Expected to receive ping message within 5 seconds, but didn't")
 	}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0309bb8 and a97bd22.

📒 Files selected for processing (3)
  • client/client.go (2 hunks)
  • client/transport/streamable_http.go (2 hunks)
  • client/transport/streamable_http_test.go (4 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • client/client.go
  • client/transport/streamable_http.go
🧰 Additional context used
🧬 Code graph analysis (1)
client/transport/streamable_http_test.go (1)
client/transport/interface.go (2)
  • JSONRPCRequest (57-62)
  • JSONRPCResponse (64-73)
🔇 Additional comments (1)
client/transport/streamable_http_test.go (1)

647-670: Shorten ping interval to reduce test flakiness and total wall time

3s ticks can be borderline on slow CI; reduce to 500ms without changing semantics.

-			pingTicker := time.NewTicker(3 * time.Second)
+			pingTicker := time.NewTicker(500 * time.Millisecond)

Verification inconclusive: the provided test run failed to start the test server (error "failed to start telemetry sidecar: os.Executable: readlink /proc/self/exe: no such file or directory") and showed repeated "dial tcp 127.0.0.1:NNNNN: connect: connection refused". Re-run after fixing test startup:

go test ./client/transport -run ^TestContinuousListening$ -count=50 -timeout 60s -v

@havasik havasik force-pushed the streamable-handle-ping branch from a97bd22 to cb3cb1b Compare September 11, 2025 13:24
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
client/transport/streamable_http_test.go (2)

647-670: Speed up ping loop to reduce test flakiness and runtime.

A 3s ticker can push close to the 5s assertion window below. Shorten the interval for tests to increase determinism.

- pingTicker := time.NewTicker(3 * time.Second)
+ pingTicker := time.NewTicker(1 * time.Second) // faster/less flaky in tests

If you prefer not to change behavior here, consider injecting the interval (e.g., test-only option) instead of hard-coding.


774-781: Strengthen the assertion: also verify the client posted the ping response back.

Right now we assert only receipt of the server “ping”. To cover the full round-trip, consider signaling from the mock server when it receives the client’s JSON-RPC response (e.g., an ackReceived chan set in the POST handler above) and wait on it here.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a97bd22 and cb3cb1b.

📒 Files selected for processing (1)
  • client/transport/streamable_http_test.go (4 hunks)
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: leavez
PR: mark3labs/mcp-go#114
File: client/transport/sse.go:137-179
Timestamp: 2025-04-06T10:07:06.685Z
Learning: The SSE client implementation in the MCP-Go project uses a 30-second timeout for reading SSE events to match the behavior of the original implementation before the transport layer refactoring.
🧬 Code graph analysis (1)
client/transport/streamable_http_test.go (2)
mcp/types.go (3)
  • JSONRPCRequest (325-330)
  • JSONRPCResponse (339-343)
  • Result (246-250)
client/transport/interface.go (2)
  • JSONRPCRequest (57-62)
  • JSONRPCResponse (64-73)
🔇 Additional comments (1)
client/transport/streamable_http_test.go (1)

709-725: LGTM: request handler correctly responds to server “ping”.

Response mirrors the incoming ID and returns a JSON-RPC 2.0 result; channel signal makes the assertion straightforward.

Comment on lines 557 to 571
// Handle ping responses from client
if _, hasResult := request["result"]; hasResult {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
if err := json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": request["id"],
"result": "response received",
}); err != nil {
http.Error(w, "Failed to encode response acknowledgment", http.StatusInternalServerError)
return
}
return
}

Copy link
Contributor

@coderabbitai coderabbitai bot Sep 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Tighten JSON-RPC response (ping ACK) detection; current check is too loose.

Gate on a valid JSON-RPC response shape (jsonrpc, id, no method) before treating any POST-with-result as an ACK. This avoids accidentally ACKing non-response payloads.

Apply within this block:

- // Handle ping responses from client
- if _, hasResult := request["result"]; hasResult {
+ // Handle client JSON-RPC responses (e.g., ping replies)
+ if request["jsonrpc"] == "2.0" && request["id"] != nil && request["method"] == nil {
+   if _, hasResult := request["result"]; hasResult {
      w.Header().Set("Content-Type", "application/json")
      w.WriteHeader(http.StatusAccepted)
      if err := json.NewEncoder(w).Encode(map[string]any{
        "jsonrpc": "2.0",
        "id":      request["id"],
        "result":  "response received",
      }); err != nil {
        http.Error(w, "Failed to encode response acknowledgment", http.StatusInternalServerError)
        return
      }
      return
- }
+   }
+ }

If you want to be strict with MCP spec semantics (HTTP status/body for ACKs), I can confirm against the 2025-03-26 ping utility section.


🌐 Web query:

What HTTP status code and response body (if any) does the MCP Streamable HTTP spec prescribe for client-to-server responses to server-initiated "ping" requests (2025-03-26 spec)?

💡 Result:

Answer: The client must POST a JSON‑RPC response to the server's ping (e.g. {"jsonrpc":"2.0","id":"123","result":{}}). Because that POST consists solely of a JSON‑RPC response, the Streamable HTTP spec requires the server to reply with HTTP 202 Accepted and no response body. [1][2]

Sources:
[1] MCP Streamable HTTP — Transports (Protocol revision 2025‑03‑26).
[2] MCP — Ping utility (Protocol revision 2025‑03‑26).


Tighten JSON-RPC ping ACK detection and return 202 with no body (spec requires empty body).

File: client/transport/streamable_http_test.go:557–571 — Gate on a valid JSON‑RPC response shape (request["jsonrpc"] == "2.0" && request["id"] != nil && request["method"] == nil) before treating a POST-with-"result" as an ACK. When ACKing, respond with HTTP 202 Accepted and no response body (remove the JSON encoding and Content-Type header) per MCP Streamable HTTP spec (2025-03-26).

🤖 Prompt for AI Agents
In client/transport/streamable_http_test.go around lines 557 to 571, the handler
currently treats any POST containing "result" as a ping ACK and returns a 202
with a JSON body and Content-Type; tighten detection to only consider a valid
JSON-RPC response shape (request["jsonrpc"] == "2.0" && request["id"] != nil &&
request["method"] == nil && request["result"] != nil) before treating it as an
ACK, and change the response to set status to http.StatusAccepted (202) with no
response body and no Content-Type header (remove json encoding and any
Write/Encode calls) to match the spec.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@havasik could you please address this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Copy link
Author

@havasik havasik Sep 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ezynda3 just addressed above, here: a85fb4c - tests are successful

@ezynda3 ezynda3 added status: needs submitter response Waiting for feedback from issue opener type: bug Something isn't working as expected labels Sep 19, 2025
@havasik havasik requested a review from ezynda3 September 22, 2025 15:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
status: needs submitter response Waiting for feedback from issue opener type: bug Something isn't working as expected
Projects
None yet
Development

Successfully merging this pull request may close these issues.

feature: Implement proactive HTTP client keepalive ping mechanism
2 participants