Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions conformance/expected_failures.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
server:
# SEP-1699 resumability: the server sends no priming event or `retry` field on a POST SSE stream, and
# the referee warns about each of the two; the baseline counts a warning as a failure. The resume on
# `Last-Event-ID` is not exercised, since the server does not close the stream mid-call.
- server-sse-polling
# SEP-2663 (io.modelcontextprotocol/tasks): the server does not implement the tasks extension.
# `tasks-status-notifications` is intentionally absent: the referee runs no check in it, and a baseline entry
# for a scenario with no failing check is flagged stale.
Expand Down
16 changes: 14 additions & 2 deletions conformance/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require "set"
require "uri"
require_relative "../lib/mcp"
require_relative "../examples/in_memory_event_store"

module Conformance
# 1x1 red PNG pixel (matches TypeScript SDK and Python SDK)
Expand Down Expand Up @@ -319,7 +320,13 @@ class TestReconnection < MCP::Tool
description "A tool that triggers SSE stream closure to test client reconnection behavior"

class << self
def call(**_args)
# SEP-1699: the response stream is closed mid-call, so the referee has to reconnect with `Last-Event-ID`
# to receive the second log message and the result, as the TypeScript and Python fixtures do.
def call(server_context:, **_args)
server_context.notify_log_message(data: "Before disconnect", level: "info", logger: "test_reconnection")
server_context.close_sse_stream
sleep(0.2)
server_context.notify_log_message(data: "After reconnect", level: "info", logger: "test_reconnection")
MCP::Tool::Response.new([MCP::Content::Text.new("Reconnection test completed").to_h])
end
end
Expand Down Expand Up @@ -909,7 +916,12 @@ def initialize(port: DEFAULT_PORT)

def start
server = build_server
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
transport = MCP::Server::Transports::StreamableHTTPTransport.new(
server,
# SEP-1699: lets `test_reconnection` close its stream and the referee resume it with `Last-Event-ID`.
event_store: InMemoryEventStore.new,
retry_interval: 100,
)
configure_handlers(server)
rack_app = build_rack_app(transport)

Expand Down
18 changes: 17 additions & 1 deletion conformance/server_runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ class ServerRunner
SERVER_POLL_INTERVAL = 0.5
SERVER_HEALTH_CHECK_RETRIES = (SERVER_START_TIMEOUT / SERVER_POLL_INTERVAL).to_i

# The referee runs on whatever Node `npx` finds. On Node 26, `fetch` rejects a promise without a reason when
# a response body is cancelled on a pooled connection while the server's next frame is still in flight,
# which the `server-sse-multiple-streams` scenario does right after reading a stream's priming event; nothing is
# reported by that rejection, but it ends the referee process mid-suite. Node 22 and 24 (the version CI runs)
# do not reject. The warning keeps the suite running; the referee's own checks decide the result as before.
# Appended after any `NODE_OPTIONS` the environment sets, so it wins over an `--unhandled-rejections` value given
# there: the referee is the only process this script starts, and a strict setting would end it the same way.
REFEREE_NODE_OPTIONS = "--unhandled-rejections=warn"

def initialize(port: Server::DEFAULT_PORT, scenario: nil, spec_version: nil, requirements: nil, verbose: false)
@port = port
@scenario = scenario
Expand Down Expand Up @@ -87,7 +96,7 @@ def run_conformance(command, server_pid:)

conformance_exit_code = nil
begin
system(*command)
system(referee_env, *command)
conformance_exit_code = $CHILD_STATUS.exitstatus
ensure
terminate_server(server_pid)
Expand All @@ -96,6 +105,13 @@ def run_conformance(command, server_pid:)
exit(conformance_exit_code || 1) unless conformance_exit_code == 0
end

# Appends the referee's Node options to any the environment already sets.
def referee_env
options = [ENV["NODE_OPTIONS"], REFEREE_NODE_OPTIONS].compact.reject(&:empty?)

{ "NODE_OPTIONS" => options.join(" ") }
end

def terminate_server(pid)
Process.kill("TERM", pid)
rescue Errno::ESRCH
Expand Down
66 changes: 66 additions & 0 deletions docs/_server/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,72 @@ This mode is suitable for simple tool servers that do not need server-initiated
> Requests of the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) of MCP 2026-07-28 ignore `enable_json_response:`
> and are served as SSE-framed single exchanges, so request-scoped notifications still reach the client.

### Resumable SSE Streams

Per MCP 2025-11-25 (SEP-1699), a client whose SSE stream is disconnected can reconnect with a GET carrying
the `Last-Event-ID` header, and the server replays the events the client missed and resumes the stream from that point.
`StreamableHTTPTransport` does this once it is given an event store, which keeps each stream's events under the ids it assigns.
The SDK defines the contract of a store, `MCP::Server::Transports::EventStore`, and ships no store of its own;
[`examples/in_memory_event_store.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/in_memory_event_store.rb)
is one to copy into an application:

```ruby
require_relative "in_memory_event_store" # copied from the SDK's examples

transport = MCP::Server::Transports::StreamableHTTPTransport.new(
server,
event_store: InMemoryEventStore.new,
retry_interval: 1000,
)
```

With an event store, the SSE events the transport writes carry the `id:` the store assigned them, and each stream opens with a priming event:
an event with an id, the `retry` field when `retry_interval:` is set (the milliseconds the client waits before reconnecting), and empty data,
so the client has a cursor to resume from before anything else is sent. A client that negotiated a protocol version before 2025-11-25
receives no priming event, since it cannot parse an event with empty data; its other events still carry ids.
An event the store failed to take is written without an id and the failure is reported, so the client's cursor stays at the event before it,
and the events of the session's GET stream are stored from the moment the client opens that stream, the earliest point it can hold a cursor into them.
A GET with `Last-Event-ID` resumes the stream the event belongs to, whether the session's GET stream or the response stream of a POST:
the events after that id are replayed, and when the request is still running, the new connection takes the old one's place,
so the handler's remaining notifications and its response reach the client. A GET stream whose connection fails is detached from the session
rather than ending it, so the client resumes it the same way. A resume whose replay passes keep finding events, because events keep being stored
while each batch is written, is closed after its sixteenth batch instead, and the client reconnects from its new cursor after `retry`.
The id must name an event of the requesting session;
an id of another session, or one the store no longer holds, is answered with HTTP 400 and nothing is replayed.

The example `InMemoryEventStore` keeps events in the process, which suits the single-process deployments the session store needs anyway.
It is bounded: each stream keeps at most `max_events_per_stream:` events (default 1024), at most `max_streams:` streams are kept (default 1024),
and their messages take at most `max_bytes:` in all (default 64 MiB), apart from the stream written to last, which is never dropped;
the oldest events of a stream and the least recently written streams go first.
The store keeps what it holds, responses included, after the request completes and after the session ends, until the bounds drop it,
and the bounds are shared by every session of the transport, so the traffic of one session can evict the events another would resume from.
A deployment whose reconnects may reach another process implements `MCP::Server::Transports::EventStore` against a shared backend;
the class documents the two methods a store provides.

A handler can also close its own connection on purpose, so a long-running operation does not hold one open:

```ruby
class SlowTool < MCP::Tool
class << self
def call(server_context:)
server_context.close_sse_stream # the client reconnects after `retry_interval`
result = compute_for_a_while
MCP::Tool::Response.new([{ type: "text", text: result }])
end
end
end
```

`close_sse_stream` returns `true` when it closed the connection of the current request's stream and `false` when there was nothing to close:
no event store, a client older than 2025-11-25, a request answered as JSON, or a connection whose priming event the store failed to take.
The request keeps running either way,
and its remaining messages are stored for the reconnect. `close_standalone_sse_stream` does the same for the session's GET stream.

{: .note }
> Requests of the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) (MCP 2026-07-28) are single exchanges
> whose streams are not resumable, as that revision specifies, so a `Last-Event-ID` header on them is ignored.
> Stateless mode (`stateless: true`) has no sessions to resume, so it does not accept `event_store:`.

### Session Limits

By default, stateful sessions are bounded in three ways: they expire after `session_idle_timeout` seconds of inactivity
Expand Down
15 changes: 15 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,21 @@ $ ruby examples/modern_http_client.rb

The same server still accepts the legacy `initialize` flow: the transport routes each request to the legacy or modern lifecycle by its `MCP-Protocol-Version` header.

### 10. In-Memory Event Store (`in_memory_event_store.rb`)

An `MCP::Server::Transports::EventStore` for resumable SSE streams (MCP 2025-11-25, SEP-1699) that keeps events in the process. The SDK ships the contract of a store and no store of its own,
so this file is meant to be copied into an application.

**Usage:**

```ruby
require_relative "in_memory_event_store"

transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, event_store: InMemoryEventStore.new)
```

It suits a single process and is bounded by events per stream, by streams, and by bytes. A deployment whose reconnects may reach another process needs a store on a shared backend instead.

### Testing with MCP Inspector

[MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) is a browser-based tool for testing and debugging MCP servers.
Expand Down
102 changes: 102 additions & 0 deletions examples/in_memory_event_store.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# frozen_string_literal: true

require "securerandom"
require "mcp"

# An `MCP::Server::Transports::EventStore` that keeps events in the process, for the single-process deployments
# `MCP::Server::Transports::StreamableHTTPTransport` serves sessions from. The SDK ships the `EventStore` contract
# and no store of its own: copy this file into an application and pass an instance as `event_store:`.
#
# transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, event_store: InMemoryEventStore.new)
#
# The store is bounded in three ways, since it retains what handlers emit for clients that may never come back:
# each stream keeps at most `max_events_per_stream` events, dropping its oldest; at most `max_streams` streams are kept,
# and the total size of the retained messages stays under `max_bytes`, both by dropping the streams written to least recently.
# A dropped event can no longer be resumed from: a client naming it in `Last-Event-ID` is told so by the transport and has to start over.
#
# A deployment whose reconnects may reach another process needs a store on a shared backend instead.
class InMemoryEventStore < MCP::Server::Transports::EventStore
DEFAULT_MAX_EVENTS_PER_STREAM = 1024
DEFAULT_MAX_STREAMS = 1024
DEFAULT_MAX_BYTES = 64 * 1024 * 1024

def initialize(max_events_per_stream: DEFAULT_MAX_EVENTS_PER_STREAM, max_streams: DEFAULT_MAX_STREAMS, max_bytes: DEFAULT_MAX_BYTES)
super()

{ max_events_per_stream: max_events_per_stream, max_streams: max_streams, max_bytes: max_bytes }.each do |name, value|
raise ArgumentError, "#{name} must be a positive Integer" unless value.is_a?(Integer) && value.positive?
end

@max_events_per_stream = max_events_per_stream
@max_streams = max_streams
@max_bytes = max_bytes
@mutex = Mutex.new
# Maps a stream id to `{ nonce: String, sequence: Integer, bytes: Integer, events: [[event_id, message], ...] }`, in order of
# the last write to each stream (a written stream is moved to the end), so the first entry is the one
# to drop when the bounds are reached.
@streams = {}
@total_bytes = 0
end

# Event ids are `<stream_id>/<nonce>-<sequence>`: the stream is found again from an id without an index, the sequence orders
# the events of a stream however many were dropped before the one the client names, and the nonce, drawn when the stream is
# first written to, marks that life of the stream. A stream dropped and written to again gets a new nonce, so the id a client
# still holds from the dropped one matches nothing in the new one and is refused rather than matched to a newer event;
# and nothing in an id counts the events of other streams.
def store_event(stream_id, message)
stream_id = stream_id.to_s
bytes = message.nil? ? 0 : message.bytesize

@mutex.synchronize do
stream = @streams.delete(stream_id) || { nonce: SecureRandom.hex(8), sequence: 0, bytes: 0, events: [] }
stream[:sequence] += 1
event_id = "#{stream_id}/#{stream[:nonce]}-#{stream[:sequence]}"
stream[:events] << [event_id, message].freeze
stream[:bytes] += bytes
@total_bytes += bytes

while stream[:events].size > @max_events_per_stream
_dropped_id, dropped_message = stream[:events].shift
dropped_bytes = dropped_message.nil? ? 0 : dropped_message.bytesize
stream[:bytes] -= dropped_bytes
@total_bytes -= dropped_bytes
end

@streams[stream_id] = stream
drop_oldest_streams

event_id
end
end

def events_after(last_event_id)
return unless last_event_id.is_a?(String)

stream_id, separator, _sequence = last_event_id.rpartition("/")
return if separator.empty?

@mutex.synchronize do
stream = @streams[stream_id]
return unless stream

index = stream[:events].index { |event_id, _message| event_id == last_event_id }
return unless index

events = stream[:events].drop(index + 1).reject { |_event_id, message| message.nil? }

[stream_id, events]
end
end

private

# The stream just written is never dropped: a message larger than `max_bytes` on its own would otherwise
# evict the stream it was stored on and make its own event id unusable at once.
def drop_oldest_streams
while @streams.size > 1 && (@streams.size > @max_streams || @total_bytes > @max_bytes)
_dropped_id, dropped = @streams.shift

@total_bytes -= dropped[:bytes]
end
end
end
1 change: 1 addition & 0 deletions lib/mcp/server/transports.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
module MCP
class Server
module Transports
autoload :EventStore, "mcp/server/transports/event_store"
autoload :StdioTransport, "mcp/server/transports/stdio_transport"
autoload :StreamableHTTPTransport, "mcp/server/transports/streamable_http_transport"
end
Expand Down
34 changes: 34 additions & 0 deletions lib/mcp/server/transports/event_store.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# frozen_string_literal: true

module MCP
class Server
module Transports
# The contract of the store `StreamableHTTPTransport` keeps SSE events in for resumability (MCP 2025-11-25,
# SEP-1699): the transport stores each event it writes under the id the store assigns, and a client that
# reconnects with `Last-Event-ID` is sent the events of that stream stored after the one it names.
#
# A store serves every session of the transport. Stream ids are opaque strings the transport mints,
# one for the GET stream of a session and one for the response stream of each request; a store orders
# the events of one stream by storage order and never has to relate streams to each other.
# The SDK ships this contract and no store of its own. `examples/in_memory_event_store.rb` in its repository
# is one that keeps events in the process, to copy into an application; a deployment whose reconnects may reach
# another process implements this interface against a shared backend.
class EventStore
# Stores `message`, the JSON text of a JSON-RPC message, on the stream `stream_id` and returns the id
# assigned to it, a String without line breaks that is unique across the store. `message` is `nil` for
# the priming event that opens a stream, which is stored so that its id is a cursor like any other,
# but is never replayed.
def store_event(stream_id, message)
raise NotImplementedError, "#{self.class} must implement #store_event"
end

# The events stored on the same stream after the event `last_event_id`, as `[stream_id, events]`
# where `events` is an Array of `[event_id, message]` pairs in storage order, priming events left out;
# `nil` when the store does not know `last_event_id`, which includes an event it has since dropped.
def events_after(last_event_id)
raise NotImplementedError, "#{self.class} must implement #events_after"
end
end
end
end
end
Loading
Loading