Skip to content
Merged
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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1425,6 +1425,22 @@ Set `stateless: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
```

You can enable JSON response mode, where the server returns `application/json` instead of `text/event-stream`.
Set `enable_json_response: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`:

```ruby
# JSON response mode
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, enable_json_response: true)
```

In JSON response mode, the POST response is a single JSON object, so server-to-client messages
that need to arrive during request processing are not supported:
request-scoped notifications (`progress`, `log`) are silently dropped, and all server-to-client requests
(`sampling/createMessage`, `roots/list`, `elicitation/create`) raise an error.
Session-scoped standalone notifications (`resources/updated`, `elicitation/complete`) and
broadcast notifications (`tools/list_changed`, etc.) still flow to clients connected to the GET SSE stream.
This mode is suitable for simple tool servers that do not need server-initiated requests.

By default, sessions do not expire. To mitigate session hijacking risks, you can set a `session_idle_timeout` (in seconds).
When configured, sessions that receive no HTTP requests for this duration are automatically expired and cleaned up:

Expand Down
21 changes: 17 additions & 4 deletions lib/mcp/server/transports/streamable_http_transport.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,14 @@ class StreamableHTTPTransport < Transport
"Connection" => "keep-alive",
}.freeze

def initialize(server, stateless: false, session_idle_timeout: nil)
def initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: nil)
super(server)
# Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
@sessions = {}
@mutex = Mutex.new

@stateless = stateless
@enable_json_response = enable_json_response
@session_idle_timeout = session_idle_timeout
@pending_responses = {}

Expand All @@ -43,7 +44,8 @@ def initialize(server, stateless: false, session_idle_timeout: nil)
start_reaper_thread if @session_idle_timeout
end

REQUIRED_POST_ACCEPT_TYPES = ["application/json", "text/event-stream"].freeze
REQUIRED_POST_ACCEPT_TYPES_SSE = ["application/json", "text/event-stream"].freeze
REQUIRED_POST_ACCEPT_TYPES_JSON = ["application/json"].freeze
REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
SESSION_REAP_INTERVAL = 60
Expand Down Expand Up @@ -94,6 +96,12 @@ def send_notification(method, params = nil, session_id: nil, related_request_id:

result = @mutex.synchronize do
if session_id
# JSON response mode returns a single JSON object as the POST response,
# so request-scoped notifications (e.g. progress, log) cannot be delivered
# alongside it. Session-scoped standalone notifications
# (e.g. `resources/updated`, `elicitation/complete`) still flow via GET SSE.
next false if @enable_json_response && related_request_id

# Send to specific session
if (session = @sessions[session_id])
stream = active_stream(session, related_request_id: related_request_id)
Expand Down Expand Up @@ -172,6 +180,10 @@ def send_request(method, params = nil, session_id: nil, related_request_id: nil)
raise "Stateless mode does not support server-to-client requests."
end

if @enable_json_response
raise "JSON response mode does not support server-to-client requests."
end

unless session_id
raise "session_id is required for server-to-client requests."
end
Expand Down Expand Up @@ -278,7 +290,8 @@ def send_ping_to_stream(stream)
end

def handle_post(request)
accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES)
required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE
accept_error = validate_accept_header(request, required_types)
return accept_error if accept_error

content_type_error = validate_content_type(request)
Expand Down Expand Up @@ -519,7 +532,7 @@ def handle_regular_request(body_string, session_id, related_request_id: nil)
end
end

if session_id && !@stateless
if session_id && !@stateless && !@enable_json_response
handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
else
response = dispatch_handle_json(body_string, server_session)
Expand Down
Loading