Skip to content

Conversation

@yuehaii
Copy link
Contributor

@yuehaii yuehaii commented Nov 24, 2025

Description

If there is adding/removing tool of the server, it will trigger the notification. And if the client try to call tool at this time, it will get failure.

Fixes #638

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

Additional Information

The root cause is that the notification already upgrade client communication to SSE. But session.upgradeToSSE.Load() is always false and go into incorrect http stream processing logic.
I have verify the solution locally and will add a test case for such special scenario later.

Summary by CodeRabbit

  • Improvements

    • Notification delivery can now optionally upgrade individual sessions to Server‑Sent Events (SSE) when supported, improving real‑time delivery for those sessions without changing public APIs.
  • Tests

    • Added a regression test confirming that adding a tool during an in‑progress call does not break the in‑flight response and that the final result is delivered successfully.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 24, 2025

Walkthrough

Adds a per-session SSE upgrade invocation before sending notifications and introduces a regression test that verifies adding a tool during an in-progress tool call does not break a streamable HTTP client response.

Changes

Cohort / File(s) Summary
Per-session SSE upgrade check
server/session.go
In sendNotificationToAllClients, detect sessions implementing SessionWithStreamableHTTPConfig and call UpgradeToSSEWhenReceiveNotification() on them before delivering notifications.
Regression test for streamable HTTP
server/streamable_http_test.go
Add TestStreamableHTTP_AddToolDuringToolCall which starts a slow tool call, adds another tool mid-call, and asserts the in-flight call completes with expected HTTP 200 and contains the tool result (handles SSE/multipart responses).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

  • Review the type assertion and nil-safety when checking for SessionWithStreamableHTTPConfig.
  • Inspect concurrency/race implications of upgrading transport inside the notification loop.
  • Verify the regression test timing assumptions and robustness against flakiness (sleep/duration choices and SSE parsing).

Possibly related PRs

Suggested labels

type: bug

Suggested reviewers

  • dugenkui03
  • ezynda3
  • pottekkat

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Out of Scope Changes check ❓ Inconclusive The code change in session.go addresses the bug, but a regression test was added despite the author stating tests would be added 'later', creating ambiguity about scope completion. Clarify whether adding the regression test now versus later aligns with the intended scope, as the comment requested this specific test case.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: notification break the client tool call' clearly describes the main bug being fixed: notifications causing client tool calls to fail.
Description check ✅ Passed The PR description includes all required sections: description of the problem, issue reference (#638), Type of Change selection, and Checklist items completed appropriately.
Linked Issues check ✅ Passed Changes address the core issue: invoking UpgradeToSSEWhenReceiveNotification() before delivering notifications ensures session.upgradeToSSE state is properly set, preventing incorrect HTTP stream processing that was causing 'unexpected nil response' errors.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • 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.

Signed-off-by: hai.yue <hai.yue@ingka.com>
@ezynda3
Copy link
Contributor

ezynda3 commented Nov 25, 2025

@yuehaii would you mind adding a regression test for this? Something like the following?

// TestStreamableHTTP_AddToolDuringToolCall tests that adding a tool while a tool call
// is in progress doesn't break the client's response.
// This is a regression test for issue #638 where notifications sent via
// sendNotificationToAllClients during an in-progress request would cause
// the response to fail with "unexpected nil response".
func TestStreamableHTTP_AddToolDuringToolCall(t *testing.T) {
	mcpServer := NewMCPServer("test-mcp-server", "1.0",
		WithToolCapabilities(true), // Enable tool list change notifications
	)
	// Add a tool that takes some time to complete
	mcpServer.AddTool(mcp.NewTool("slow_tool",
		mcp.WithDescription("A tool that takes time to complete"),
	), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		// Simulate work that takes some time
		time.Sleep(100 * time.Millisecond)
		return mcp.NewToolResultText("done"), nil
	})
	server := NewTestStreamableHTTPServer(mcpServer, WithStateful(true))
	defer server.Close()
	// Initialize to get session
	resp, err := postJSON(server.URL, initRequest)
	if err != nil {
		t.Fatalf("Failed to initialize: %v", err)
	}
	sessionID := resp.Header.Get(HeaderKeySessionID)
	resp.Body.Close()
	if sessionID == "" {
		t.Fatal("Expected session ID in response header")
	}
	// Start the tool call in a goroutine
	resultChan := make(chan struct {
		statusCode int
		body       string
		err        error
	})
	go func() {
		toolRequest := map[string]any{
			"jsonrpc": "2.0",
			"id":      1,
			"method":  "tools/call",
			"params": map[string]any{
				"name": "slow_tool",
			},
		}
		toolBody, _ := json.Marshal(toolRequest)
		req, _ := http.NewRequest("POST", server.URL, bytes.NewReader(toolBody))
		req.Header.Set("Content-Type", "application/json")
		req.Header.Set(HeaderKeySessionID, sessionID)
		resp, err := server.Client().Do(req)
		if err != nil {
			resultChan <- struct {
				statusCode int
				body       string
				err        error
			}{0, "", err}
			return
		}
		defer resp.Body.Close()
		body, _ := io.ReadAll(resp.Body)
		resultChan <- struct {
			statusCode int
			body       string
			err        error
		}{resp.StatusCode, string(body), nil}
	}()
	// Wait a bit then add a new tool while the slow_tool is executing
	// This triggers sendNotificationToAllClients
	time.Sleep(50 * time.Millisecond)
	mcpServer.AddTool(mcp.NewTool("new_tool",
		mcp.WithDescription("A new tool added during execution"),
	), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		return mcp.NewToolResultText("new tool result"), nil
	})
	// Wait for the tool call to complete
	result := <-resultChan
	if result.err != nil {
		t.Fatalf("Tool call failed with error: %v", result.err)
	}
	if result.statusCode != http.StatusOK {
		t.Errorf("Expected status 200, got %d. Body: %s", result.statusCode, result.body)
	}
	// The response should contain the tool result
	// It may be SSE format (text/event-stream) due to the notification upgrade
	if !strings.Contains(result.body, "done") {
		t.Errorf("Expected response to contain 'done', got: %s", result.body)
	}
}

Signed-off-by: hai.yue <hai.yue@ingka.com>
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 (1)
server/streamable_http_test.go (1)

2255-2341: Regression scenario is well-covered; consider adding a timeout to avoid hangs

The test correctly models the bug scenario (stateful session, WithToolCapabilities(true), slow tool, concurrent AddTool, and asserting HTTP 200 + "done" in the body). That should reliably catch regressions around the nil-response behavior.

One small robustness improvement: right now, if the server path ever deadlocks or blocks indefinitely, the goroutine doing server.Client().Do(req) and the main goroutine waiting on resultChan could hang the test. You can keep the behavior the same but bound the failure mode by giving the request a context timeout:

-	go func() {
+	go func() {
 		toolRequest := map[string]any{
 			"jsonrpc": "2.0",
 			"id":      1,
 			"method":  "tools/call",
 			"params": map[string]any{
 				"name": "slow_tool",
 			},
 		}
-		toolBody, _ := json.Marshal(toolRequest)
-		req, _ := http.NewRequest("POST", server.URL, bytes.NewReader(toolBody))
+		toolBody, _ := json.Marshal(toolRequest)
+		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+		defer cancel()
+		req, _ := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, bytes.NewReader(toolBody))
 		req.Header.Set("Content-Type", "application/json")
 		req.Header.Set(HeaderKeySessionID, sessionID)
 		resp, err := server.Client().Do(req)

With this, a regression that causes the call to stall will still fail the test (via a non-nil result.err) but won’t hang the test suite. Optionally, if you ever see flakes under heavy load, you could also increase the handler sleep or coordinate start-of-work via a channel instead of relying purely on the 50ms/100ms timing window, but that’s likely overkill 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 0c4d1bc and 7070adc.

📒 Files selected for processing (1)
  • server/streamable_http_test.go (1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.go

📄 CodeRabbit inference engine (AGENTS.md)

**/*.go: Order imports: standard library first, then third-party, then local packages (goimports enforces this)
Follow Go naming conventions: exported identifiers in PascalCase; unexported in camelCase; acronyms uppercase (HTTP, JSON, MCP)
Error handling: return sentinel errors, wrap with fmt.Errorf("context: %w", err), and check with errors.Is/As
Prefer explicit types and strongly-typed structs; avoid using any except where protocol flexibility is required (e.g., Arguments any)
All exported types and functions must have GoDoc comments starting with the identifier name; avoid inline comments unless necessary
Functions that are handlers or long-running must accept context.Context as the first parameter
Ensure thread safety for shared state using sync.Mutex and document thread-safety requirements in comments
For JSON: use json struct tags with omitempty for optional fields; use json.RawMessage for flexible/deferred parsing

Files:

  • server/streamable_http_test.go
**/*_test.go

📄 CodeRabbit inference engine (AGENTS.md)

**/*_test.go: Testing: use testify/assert and testify/require
Write table-driven tests using a tests := []struct{ name, ... } pattern
Go test files must end with _test.go

Files:

  • server/streamable_http_test.go
🧠 Learnings (2)
📚 Learning: 2025-06-23T11:10:42.948Z
Learnt from: floatingIce91
Repo: mark3labs/mcp-go PR: 401
File: server/server.go:1082-1092
Timestamp: 2025-06-23T11:10:42.948Z
Learning: In Go MCP server, ServerTool.Tool field is only used for tool listing and indexing, not for tool execution or middleware. During handleToolCall, only the Handler field is used, so dynamic tools don't need the Tool field populated.

Applied to files:

  • server/streamable_http_test.go
📚 Learning: 2025-03-04T06:59:43.882Z
Learnt from: xinwo
Repo: mark3labs/mcp-go PR: 35
File: mcp/tools.go:107-137
Timestamp: 2025-03-04T06:59:43.882Z
Learning: Tool responses from the MCP server shouldn't contain RawInputSchema, which is why the UnmarshalJSON method for the Tool struct is implemented to handle only the structured InputSchema format.

Applied to files:

  • server/streamable_http_test.go
🧬 Code graph analysis (1)
server/streamable_http_test.go (4)
mcp/utils.go (1)
  • NewToolResultText (271-280)
server/streamable_http.go (2)
  • NewTestStreamableHTTPServer (1381-1385)
  • WithStateful (84-90)
client/transport/constants.go (1)
  • HeaderKeySessionID (5-5)
server/constants.go (1)
  • HeaderKeySessionID (5-5)

@yuehaii
Copy link
Contributor Author

yuehaii commented Nov 25, 2025

@yuehaii would you mind adding a regression test for this? Something like the following?

// TestStreamableHTTP_AddToolDuringToolCall tests that adding a tool while a tool call
// is in progress doesn't break the client's response.
// This is a regression test for issue #638 where notifications sent via
// sendNotificationToAllClients during an in-progress request would cause
// the response to fail with "unexpected nil response".
func TestStreamableHTTP_AddToolDuringToolCall(t *testing.T) {
	mcpServer := NewMCPServer("test-mcp-server", "1.0",
		WithToolCapabilities(true), // Enable tool list change notifications
	)
	// Add a tool that takes some time to complete
	mcpServer.AddTool(mcp.NewTool("slow_tool",
		mcp.WithDescription("A tool that takes time to complete"),
	), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		// Simulate work that takes some time
		time.Sleep(100 * time.Millisecond)
		return mcp.NewToolResultText("done"), nil
	})
	server := NewTestStreamableHTTPServer(mcpServer, WithStateful(true))
	defer server.Close()
	// Initialize to get session
	resp, err := postJSON(server.URL, initRequest)
	if err != nil {
		t.Fatalf("Failed to initialize: %v", err)
	}
	sessionID := resp.Header.Get(HeaderKeySessionID)
	resp.Body.Close()
	if sessionID == "" {
		t.Fatal("Expected session ID in response header")
	}
	// Start the tool call in a goroutine
	resultChan := make(chan struct {
		statusCode int
		body       string
		err        error
	})
	go func() {
		toolRequest := map[string]any{
			"jsonrpc": "2.0",
			"id":      1,
			"method":  "tools/call",
			"params": map[string]any{
				"name": "slow_tool",
			},
		}
		toolBody, _ := json.Marshal(toolRequest)
		req, _ := http.NewRequest("POST", server.URL, bytes.NewReader(toolBody))
		req.Header.Set("Content-Type", "application/json")
		req.Header.Set(HeaderKeySessionID, sessionID)
		resp, err := server.Client().Do(req)
		if err != nil {
			resultChan <- struct {
				statusCode int
				body       string
				err        error
			}{0, "", err}
			return
		}
		defer resp.Body.Close()
		body, _ := io.ReadAll(resp.Body)
		resultChan <- struct {
			statusCode int
			body       string
			err        error
		}{resp.StatusCode, string(body), nil}
	}()
	// Wait a bit then add a new tool while the slow_tool is executing
	// This triggers sendNotificationToAllClients
	time.Sleep(50 * time.Millisecond)
	mcpServer.AddTool(mcp.NewTool("new_tool",
		mcp.WithDescription("A new tool added during execution"),
	), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		return mcp.NewToolResultText("new tool result"), nil
	})
	// Wait for the tool call to complete
	result := <-resultChan
	if result.err != nil {
		t.Fatalf("Tool call failed with error: %v", result.err)
	}
	if result.statusCode != http.StatusOK {
		t.Errorf("Expected status 200, got %d. Body: %s", result.statusCode, result.body)
	}
	// The response should contain the tool result
	// It may be SSE format (text/event-stream) due to the notification upgrade
	if !strings.Contains(result.body, "done") {
		t.Errorf("Expected response to contain 'done', got: %s", result.body)
	}
}

I have added this regression test. Thanks for the assist, @ezynda3

@ezynda3 ezynda3 merged commit 6bd3269 into mark3labs:main Nov 25, 2025
4 checks passed
sd2k pushed a commit to grafana/mcp-go that referenced this pull request Nov 27, 2025
* feat: client roots feature

* feat: finish client roots, pass unit and integration test

* client roots http sample code

* client roots for stdio and pass integration test

* update roots stio client example

* add godoc and const of rootlist

* update godoc and data format

* update examples for client roots

* add fallback for demonstration

* adjust roots path and signals of examples

* update roots http client example

* samples: fix unit test and refactor with lint

* examples: refactor to adapt windows os and nitpick comments

* update for nitpick comments

* refactor for nitpick comments

* fix: notifications breaking the tool call
Signed-off-by: hai.yue <hai.yue@ingka.com>

* add a regression test mark3labs#642 (comment)
Signed-off-by: hai.yue <hai.yue@ingka.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: after adding tool to server, client's call fails

2 participants