Skip to content

feat: client-side streamable-http transport supports continuously listening #317

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 16 commits into
base: main
Choose a base branch
from

Conversation

leavez
Copy link
Contributor

@leavez leavez commented May 21, 2025

Description

Implemented for client-side streamable-http transport

  • option to listen continuously for server notifications with a long-live GET connection (missing feature from spec)
  • and a logger option

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

Summary by CodeRabbit

  • New Features

    • Introduced support for continuous server-to-client notifications using a persistent connection, enabling real-time updates without repeated polling.
    • Added options for enabling continuous listening and enhanced logging.
  • Bug Fixes

    • Improved error handling for session termination and unsupported server features.
  • Tests

    • Added comprehensive tests to verify continuous listening functionality and error scenarios.
  • Documentation

    • Clarified example client usage with comments on enabling continuous listening to receive global notifications.

Copy link
Contributor

coderabbitai bot commented May 21, 2025

Walkthrough

This update enhances the StreamableHTTP transport by introducing continuous server-to-client notifications using a persistent HTTP GET connection, controlled via a new option. The HTTP request logic is unified, error handling is improved, and new logger support is added. Comprehensive tests and a mock server verify the new listening feature and error scenarios.

Changes

File(s) Change Summary
client/transport/streamable_http.go Added continuous listening via persistent GET connection, unified HTTP request sending logic, improved error handling, added logger support, and updated struct and methods accordingly.
client/transport/streamable_http_test.go Added mock HTTP server with GET support, new tests for continuous listening and handling of unsupported GET method, and a test logger for capturing logs.
examples/simple_client/main.go Added explanatory comments about the default transport behavior and how to enable continuous listening to receive server notifications.

Possibly related PRs

  • mark3labs/mcp-go#168: Introduced the initial StreamableHTTP client implementation, which is directly extended and refactored in this PR to support continuous server-to-client notifications.
  • mark3labs/mcp-go#273: Implemented the server-side StreamableHTTP transport with session management and SSE streaming, complementing this PR’s client-side continuous listening enhancements.

Suggested labels

type: enhancement

Suggested reviewers

  • pottekkat

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (1.64.8)

Error: you are using a configuration file for golangci-lint v2 with golangci-lint v1: please use golangci-lint v2
Failed executing command with error: you are using a configuration file for golangci-lint v2 with golangci-lint v1: please use golangci-lint v2


📜 Recent review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4313aa1 and 1f5efb5.

📒 Files selected for processing (1)
  • examples/simple_client/main.go (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • examples/simple_client/main.go
✨ Finishing Touches
  • 📝 Generate Docstrings

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
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Explain this complex logic.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai explain this code block.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and explain its main purpose.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

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

🔭 Outside diff range comments (1)
client/transport/streamable_http.go (1)

202-210: ⚠️ Potential issue

Guard against double-closing initialized channel

close(c.initialized) is executed each time an initialize request succeeds.
A second initialize (or a retry after a transient 50x) will panic because a closed
channel cannot be closed again.

-       close(c.initialized)
+       // close only once
+       select {
+       case <-c.initialized: // already closed – nothing to do
+       default:
+               close(c.initialized)
+       }
🧹 Nitpick comments (3)
client/transport/streamable_http.go (3)

258-266: Avoid setting Content-Type on GET requests

The Content-Type header is meaningless (and sometimes confusing for proxies)
when the request has no body. It is safer to omit it for GET.

- req.Header.Set("Content-Type", "application/json")
+ if method != http.MethodGet {
+     req.Header.Set("Content-Type", "application/json")
+ }

469-476: resp.Body is closed twice

createGETConnectionToServer defers resp.Body.Close() and passes the same
reader to handleSSEResponse, which in turn closes it in readSSE. A double
close is harmless but unnecessary and can mislead future maintainers.

- defer resp.Body.Close()
+ // readSSE will close resp.Body for us

57-61: Nil-logger safety

WithLogger directly assigns the provided logger. If a caller passes nil
the transport will panic on first call. Either document the contract or add a
nil-fallback:

return func(sc *StreamableHTTP) {
-       sc.logger = logger
+       if logger != nil {
+               sc.logger = logger
+       }
}
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
Cache: Disabled due to data retention organization setting
Knowledge Base: Disabled due to data retention organization setting

📥 Commits

Reviewing files that changed from the base of the PR and between d37791c and 6435882.

📒 Files selected for processing (2)
  • client/transport/streamable_http.go (11 hunks)
  • client/transport/streamable_http_test.go (2 hunks)
🔇 Additional comments (1)
client/transport/streamable_http_test.go (1)

563-569: Dead code: returned notificationCount is never used

The helper returns notificationCount but none of the tests read the value.
Drop the return or use it; otherwise it misleads readers and linters.

[ suggest_nitpick ]

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

♻️ Duplicate comments (1)
client/transport/streamable_http.go (1)

292-295: Differentiate 404 "session terminated" from generic 404

Blindly treating every 404 as "session terminated" can hide genuine endpoint-routing issues. Consider checking a sentinel header or body message returned by the server before wiping the session id.

🧹 Nitpick comments (4)
client/transport/streamable_http.go (4)

445-466: Consider specific handling for session termination in listenForever

The listenForever method doesn't specifically check for the errSessionTerminated error, which might lead to unnecessary retries when the session is actually terminated.

func (c *StreamableHTTP) listenForever() {
	c.logger.Infof("listening to server forever")
	for {
		err := c.createGETConnectionToServer()
		if errors.Is(err, errGetMethodNotAllowed) {
			// server does not support listening
			c.logger.Errorf("server does not support listening")
			return
		}
+		if errors.Is(err, errSessionTerminated) {
+			// Session was terminated, no need to retry until reinitialization
+			c.logger.Errorf("session terminated, waiting for reinitialization")
+			// Wait for reinitialization signal
+			select {
+			case <-c.closed:
+				return
+			case <-c.initialized: // This would require resetting and recreating this channel on reinitialization
+				c.logger.Infof("session reinitialized, resuming listening")
+				continue
+			}
+		}

		select {
		case <-c.closed:
			return
		default:
		}

		if err != nil {
			c.logger.Errorf("failed to listen to server. retry in 1 second: %v", err)
		}
		time.Sleep(retryInterval)
	}
}

472-473: Consider making retryInterval configurable

The retryInterval variable is used for testing convenience, but it's not exposed for configuration. Consider making it configurable via an option function to allow users to adjust the retry behavior.

+// WithRetryInterval sets the interval between retries for continuous listening.
+func WithRetryInterval(interval time.Duration) StreamableHTTPCOption {
+	return func(sc *StreamableHTTP) {
+		sc.retryInterval = interval
+	}
+}

type StreamableHTTP struct {
	baseURL             *url.URL
	httpClient          *http.Client
	headers             map[string]string
	headerFunc          HTTPHeaderFunc
	logger              util.Logger
	getListeningEnabled bool
+	retryInterval       time.Duration

	initialized chan struct{}
	sessionID   atomic.Value // string

	notificationHandler func(mcp.JSONRPCNotification)
	notifyMu            sync.RWMutex

	closed chan struct{}
}

// In NewStreamableHTTP
smc := &StreamableHTTP{
	baseURL:      parsedURL,
	httpClient:   &http.Client{},
	headers:      make(map[string]string),
	closed:       make(chan struct{}),
	logger:       util.DefaultLogger(),
	initialized:  make(chan struct{}),
+	retryInterval: 1 * time.Second,
}

// In listenForever
time.Sleep(c.retryInterval)

477-478: Consider using a context with timeout for GET requests

The code creates a background context without timeout for GET requests. Consider using a context with timeout to prevent overly long-running requests if there are network issues.

-ctx := context.Background() // the sendHTTP will be automatically canceled when the client is closed
+ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // adjust timeout as needed
+defer cancel() // ensure resources are cleaned up
resp, err := c.sendHTTP(ctx, http.MethodGet, nil, "text/event-stream")

151-158: Use logger instead of fmt.Printf for error messages

There are several instances of fmt.Printf in the code that should be replaced with the logger for consistency. This includes lines 151, 157, 321, 329, and 382.

-fmt.Printf("failed to create close request\n: %v", err)
+c.logger.Errorf("failed to create close request: %v", err)

-fmt.Printf("failed to send close request\n: %v", err)
+c.logger.Errorf("failed to send close request: %v", err)
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
Cache: Disabled due to data retention organization setting
Knowledge Base: Disabled due to data retention organization setting

📥 Commits

Reviewing files that changed from the base of the PR and between 8d3f236 and c0f4403.

📒 Files selected for processing (1)
  • client/transport/streamable_http.go (11 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (1)
client/transport/streamable_http.go (4)
server/streamable_http.go (1)
  • WithLogger (77-81)
util/logger.go (2)
  • Logger (8-11)
  • DefaultLogger (16-20)
client/transport/interface.go (1)
  • HTTPHeaderFunc (13-13)
mcp/types.go (1)
  • MethodInitialize (19-19)
🔇 Additional comments (7)
client/transport/streamable_http.go (7)

25-36: Well-documented option for continuous listening

The WithContinuousListening option is clearly documented with references to the MCP specification. This makes it easy for users to understand the purpose and behavior of this feature.


57-61: Good addition of logger support

Adding logger support is a good practice for this type of component, especially for a feature that involves long-lived connections and retry logic.


120-126: Clean implementation of continuous listening initialization

The goroutine that waits for initialization before starting the continuous listening process is a good approach. This ensures the session ID is established before attempting to listen for server notifications.


244-298: Good refactoring of HTTP request handling

The sendHTTP method nicely centralizes the common HTTP request creation and sending logic that was previously duplicated in SendRequest and SendNotification. This improves maintainability and ensures consistent behavior.


186-192: Good handling of initialize request failures

The special handling for 404 errors during initialization is a good addition. It correctly distinguishes between genuine routing issues and session termination errors.


468-473: Clear error definitions

The error definitions are clear and help distinguish between different error conditions. This makes the error handling logic more readable and maintainable.


1-507: Overall excellent implementation of continuous listening feature

The implementation of the continuous listening feature is well-designed and follows good practices:

  1. It correctly implements the MCP specification for listening for server messages
  2. It handles errors and retries appropriately
  3. It provides clear configuration options
  4. It includes proper logging
  5. It refactors common code to reduce duplication

There are a few minor improvements suggested, but overall this is a solid implementation of the feature.

@leavez leavez changed the title Feat: client-side streamable-http transport support continuously listening Feat: client-side streamable-http transport supports continuously listening May 21, 2025
@leavez leavez changed the title Feat: client-side streamable-http transport supports continuously listening feat: client-side streamable-http transport supports continuously listening May 22, 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: 4

♻️ Duplicate comments (1)
client/transport/streamable_http.go (1)

284-354: Addresses previous 404 handling concern with improved logic

The sendHTTP method centralizes request handling and addresses the previous review comment about 404 handling. The universal 404 handling clears session ID, but the caller (SendRequest) has special logic for initialization requests to distinguish routing issues from session termination.

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

347-351: Session ID race condition in CompareAndSwap

The CompareAndSwap operation could fail if the session ID changed between reading it (line 312) and this comparison, potentially leaving a stale session ID. Consider using a more robust approach.

 	// universal handling for session terminated
 	if resp.StatusCode == http.StatusNotFound {
-		c.sessionID.CompareAndSwap(sessionID, "")
+		c.sessionID.Store("")
 		return nil, errSessionTerminated
 	}

548-579: Consider context handling in long-lived connection

Using context.Background() means the GET connection won't be canceled by request-specific contexts, which is appropriate for a long-lived listening connection. However, consider if this could lead to resource leaks.

The comment on line 550 could be more specific:

-	ctx := context.Background() // the sendHTTP will be automatically canceled when the client is closed
+	ctx := context.Background() // sendHTTP will cancel when client.closed is triggered
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cc540bb and d7ddec1.

📒 Files selected for processing (1)
  • client/transport/streamable_http.go (11 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (1)
client/transport/streamable_http.go (6)
server/streamable_http.go (1)
  • WithLogger (77-81)
util/logger.go (2)
  • Logger (8-11)
  • DefaultLogger (16-20)
client/client.go (1)
  • Client (16-25)
client/transport/interface.go (1)
  • HTTPHeaderFunc (13-13)
mcp/types.go (1)
  • MethodInitialize (19-19)
client/oauth.go (1)
  • OAuthAuthorizationRequiredError (48-48)
🔇 Additional comments (7)
client/transport/streamable_http.go (7)

20-20: LGTM: Appropriate import addition

The util package import is correctly added to support the new logger functionality.


25-36: Well-documented continuous listening option

The option is properly documented with clear explanation of functionality and links to the MCP specification. The warning about server support is helpful.


57-61: Standard logger option implementation

Follows the same pattern as the server-side WithLogger option and properly integrates with the struct.


116-117: Good default logger initialization

Properly initializes the logger with a default and creates the coordination channel.


473-473: Good refactoring to use centralized HTTP handling

The SendNotification method correctly uses the new sendHTTP method for consistency.


518-539: Robust retry logic with proper error handling

The continuous listening implementation properly handles different error scenarios and includes appropriate logging. The check for method not allowed prevents infinite retries when server doesn't support GET.


541-546: Well-defined error constants

Clear error definitions with a configurable retry interval for testing flexibility.

@dugenkui03 dugenkui03 added the area: mcp spec Issues related to MCP specification compliance label May 29, 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

🔭 Outside diff range comments (1)
client/transport/streamable_http.go (1)

385-386: 🛠️ Refactor suggestion

Inconsistent error logging - use structured logger.

Several locations use fmt.Printf for error logging instead of the configured logger, which bypasses the logging infrastructure.

Replace fmt.Printf calls with the structured logger:

-			fmt.Printf("failed to unmarshal message: %v\n", err)
+			c.logger.Errorf("failed to unmarshal message: %v", err)
-				fmt.Printf("failed to unmarshal notification: %v\n", err)
+				c.logger.Errorf("failed to unmarshal notification: %v", err)
-					fmt.Printf("SSE stream error: %v\n", err)
+					c.logger.Errorf("SSE stream error: %v", err)

Also applies to: 393-394, 446-447

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

292-362: Excellent refactoring with minor context leak concern.

The sendHTTP method effectively consolidates HTTP request logic, eliminating duplication. However, there's a potential goroutine leak in the context cancellation logic.

In lines 301-308, the goroutine for context cancellation may not be cleaned up if the HTTP request completes quickly. Consider this pattern instead:

-	go func() {
-		select {
-		case <-c.closed:
-			cancel()
-		case <-newCtx.Done():
-			// The original context was canceled, no need to do anything
-		}
-	}()
+	go func() {
+		defer cancel()
+		select {
+		case <-c.closed:
+		case <-newCtx.Done():
+		}
+	}()

This ensures the cancel function is always called, cleaning up resources properly.


549-554: Consider making retry interval configurable.

The hardcoded 1-second retry interval may not be suitable for all deployment scenarios.

Consider adding a configuration option for the retry interval:

+func WithRetryInterval(interval time.Duration) StreamableHTTPCOption {
+	return func(sc *StreamableHTTP) {
+		sc.retryInterval = interval
+	}
+}

And add a retryInterval field to the struct with a default value.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 42ba0ff and 928d9ea.

📒 Files selected for processing (1)
  • client/transport/streamable_http.go (10 hunks)
🔇 Additional comments (11)
client/transport/streamable_http.go (11)

20-20: Import addition looks good.

The addition of the util package import supports the new logger functionality.


25-36: Well-documented continuous listening option.

The function documentation clearly explains the feature and includes relevant specification links. Good defensive notice about server support variability.


57-61: Logger option implementation is correct.

Simple and straightforward option function for logger configuration.


88-94: Good synchronization design for initialization coordination.

The combination of initialized channel and initializedOnce provides proper one-time signaling without risk of double-close panics.


118-119: Proper initialization of new fields.

Default logger and initialized channel are correctly set up in the constructor.


139-149: Excellent fix for goroutine leak prevention.

The select statement properly handles both initialization completion and client closure, addressing the previous review concern about potential goroutine leaks.


223-232: Good differentiation of 404 errors during initialization.

This addresses the previous review feedback by treating 404 errors during initialization as routing issues rather than session termination, which is the correct behavior.


261-263: Proper use of sync.Once for channel coordination.

The initializedOnce.Do() ensures the channel is closed exactly once, preventing panics and addressing previous review concerns about missing channel closure on error paths.


481-481: Good refactoring to use common HTTP method.

The use of sendHTTP eliminates code duplication and ensures consistent behavior across request types.


526-547: Robust listening implementation with proper error handling.

The retry logic and graceful handling of unsupported servers (405 Method Not Allowed) is well-implemented. The infinite loop with proper exit conditions is correct.


556-587: Well-structured GET connection handling.

The method properly handles different response scenarios including method not allowed, status code validation, and content type verification. The error wrapping provides good debugging context.

}

// Start the transport - this will launch listenForever in a goroutine
if err := trans.Start(context.Background()); err != nil {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why is the SendRequest#initialize method called before Start()?

Copy link
Contributor Author

@leavez leavez May 29, 2025

Choose a reason for hiding this comment

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

I've fixed this.

It will not affect the test results, as start in http transport is doing nothing for sending

@dugenkui03
Copy link
Collaborator

@leavez Please let me know when this PR is ready, and I'd like to review it.

Also, it will be great If you can provide some examples in /examples or create a http_test.go in client, to demonstrate how to use this capability with client.Client

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area: mcp spec Issues related to MCP specification compliance
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants