Repository: modelcontextprotocol/go-sdk (2026-03-27T15:02:39Z)

Issue:
MemoryEventStore: panic "no progress during purge" when first chunk per stream is empty (SSE priming Append(nil))
**Describe the bug**

`MemoryEventStore.purge()` can panic with `panic: no progress during purge` when the store is over `MaxBytes` and a purge pass only removes **leading empty** chunks (`[]byte` / `nil` with length 0). Purge treats progress only when `removeFirst()` returns `r > 0`, so if every stream’s first chunk is empty (but later chunks hold real payload), one pass removes only those empty chunks with `r == 0`, `changed` stays false, and the code panics.

That situation is not hypothetical: for SSE with an `EventStore` and protocol ≥ 2025-11-25, `streamableServerConn` calls `Append(..., nil)` for the **priming** event so indexes match the wire, which inserts a **zero-length first chunk** per stream. Under memory pressure, purge can hit this path.

**To Reproduce**

Steps to reproduce the behavior:

1. Use `NewMemoryEventStore` with a **small** `MaxBytes` (e.g. `SetMaxBytes(50)` after creation).
2. `Open` a session and stream ID.
3. `Append(nil)` (same as the SDK’s priming append).
4. `Append` a non-empty payload large enough that total stored bytes exceed `MaxBytes` (e.g. 100 bytes).
5. `Append` again with more payload so `purge()` runs while the list still looks like `[nil, …payload…]` with the empty chunk first.

The last `Append` can trigger the panic during `purge()` inside `Append`.

**Expected behavior**

Purge should evict data until `nBytes <= MaxBytes` (or until no evictable data remains) **without panicking**. Removing a leading empty chunk should count as progress so the next iteration can evict non-empty bytes.

**Logs**

Example panic / goroutine header:

```text
panic: no progress during purge

goroutine ... [running]:
github.com/modelcontextprotocol/go-sdk/mcp.(*MemoryEventStore).purge(...)
github.com/modelcontextprotocol/go-sdk/mcp.(*MemoryEventStore).Append(...)
github.com/modelcontextprotocol/go-sdk/mcp.(*streamableServerConn).Write(...)
github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2.(*Connection).write(...)
```

**Additional context**

- **Version:** e.g. `github.com/modelcontextprotocol/go-sdk` v1.4.1
- **Cause:** `mcp/event.go` `purge()` only sets `changed` when `r > 0` after `removeFirst()`; empty chunks do not increment `nBytes` but still occupy the front of `dataList`.
- **Related code:** `mcp/streamable.go` — SSE priming path stores the priming event via `Append(..., nil)` when `effectiveVersion >= protocolVersion20251125`.
- **Possible fix:** Treat any `removeFirst()` from a `dataList` with `dl.size > 0` as progress, or strip leading empty chunks in a loop until a non-empty eviction or empty list.
- **Workaround for callers:** raise `MaxBytes`, or use a custom `EventStore` implementation until fixed.



Discussion:

Patch:
diff --git a/mcp/event.go b/mcp/event.go
index 62dd2ad2..80b42f51 100644
--- a/mcp/event.go
+++ b/mcp/event.go
@@ -376,10 +376,11 @@ func (s *MemoryEventStore) purge() {
 			for _, dl := range sm {
 				if dl.size > 0 {
 					r := dl.removeFirst()
-					if r > 0 {
-						changed = true
-						s.nBytes -= r
-					}
+					// Even if we remove an empty chunk, that's
+					// still progress. There may be non-empty
+					// chunks after it.
+					changed = true
+					s.nBytes -= r
 				}
 			}
 		}


Test patch:
diff --git a/mcp/event_test.go b/mcp/event_test.go
index 685e6486..91ddb9d7 100644
--- a/mcp/event_test.go
+++ b/mcp/event_test.go
@@ -327,6 +327,29 @@ func TestMemoryEventStoreAfter(t *testing.T) {
 	}
 }
 
+func TestMemoryEventStorePurgeEmpty(t *testing.T) {
+	ctx := context.Background()
+	s := NewMemoryEventStore(nil)
+	s.SetMaxBytes(100)
+
+	// Append an empty chunk first.
+	if err := s.Append(ctx, "S1", "1", []byte("")); err != nil {
+		t.Fatal(err)
+	}
+	// Append a non-empty chunk.
+	if err := s.Append(ctx, "S1", "1", []byte("1234567890")); err != nil {
+		t.Fatal(err)
+	}
+	// Now nBytes = 10, but the first chunk is empty.
+
+	// This should not panic.
+	s.SetMaxBytes(5)
+
+	if s.nBytes > 5 {
+		t.Errorf("got nBytes %d, want <= 5", s.nBytes)
+	}
+}
+
 func BenchmarkMemoryEventStore(b *testing.B) {
 	// Benchmark with various settings for event store size, number of session,
 	// and payload size.