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:
- Use
NewMemoryEventStore with a small MaxBytes (e.g. SetMaxBytes(50) after creation).
Open a session and stream ID.
Append(nil) (same as the SDK’s priming append).
Append a non-empty payload large enough that total stored bytes exceed MaxBytes (e.g. 100 bytes).
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:
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.
Describe the bug
MemoryEventStore.purge()can panic withpanic: no progress during purgewhen the store is overMaxBytesand a purge pass only removes leading empty chunks ([]byte/nilwith length 0). Purge treats progress only whenremoveFirst()returnsr > 0, so if every stream’s first chunk is empty (but later chunks hold real payload), one pass removes only those empty chunks withr == 0,changedstays false, and the code panics.That situation is not hypothetical: for SSE with an
EventStoreand protocol ≥ 2025-11-25,streamableServerConncallsAppend(..., 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:
NewMemoryEventStorewith a smallMaxBytes(e.g.SetMaxBytes(50)after creation).Opena session and stream ID.Append(nil)(same as the SDK’s priming append).Appenda non-empty payload large enough that total stored bytes exceedMaxBytes(e.g. 100 bytes).Appendagain with more payload sopurge()runs while the list still looks like[nil, …payload…]with the empty chunk first.The last
Appendcan trigger the panic duringpurge()insideAppend.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:
Additional context
github.com/modelcontextprotocol/go-sdkv1.4.1mcp/event.gopurge()only setschangedwhenr > 0afterremoveFirst(); empty chunks do not incrementnBytesbut still occupy the front ofdataList.mcp/streamable.go— SSE priming path stores the priming event viaAppend(..., nil)wheneffectiveVersion >= protocolVersion20251125.removeFirst()from adataListwithdl.size > 0as progress, or strip leading empty chunks in a loop until a non-empty eviction or empty list.MaxBytes, or use a customEventStoreimplementation until fixed.