Skip to content

Commit 7c29bb4

Browse files
committed
v5.5.6 Fix MCP SSE event names to spec-mandated "message"
1 parent ca9292f commit 7c29bb4

6 files changed

Lines changed: 176 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
## parse-stack-next Changelog
22

3+
### 5.5.6
4+
5+
#### MCP clients now receive the SSE response instead of hanging
6+
7+
- **FIXED**: The Streamable HTTP SSE transport framed its events with custom
8+
event names — `event: progress` for `notifications/progress` and
9+
`event: response` for the final JSON-RPC response. MCP defines a single SSE
10+
event type for JSON-RPC traffic, and clients match only the default
11+
`message` type, so every frame the SDK emitted was silently discarded: tool
12+
progress never surfaced, and, critically, the terminating response never
13+
arrived, leaving the client blocked until its own timeout on any streaming
14+
`tools/call`. All frames on both the request-scoped POST stream and the
15+
server-to-client GET notification stream now carry `event: message`, and
16+
clients discriminate from the JSON-RPC envelope (`method` present for a
17+
notification, `id` plus `result`/`error` for a response) as the protocol
18+
intends. Deployments that worked around this with a middleware rewriting
19+
the event name can drop it.
20+
321
### 5.5.5
422

523
#### Agent `call_method` runs under the caller's scope, not the master key

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
parse-stack-next (5.5.5)
4+
parse-stack-next (5.5.6)
55
activemodel (>= 6.1, < 9)
66
activesupport (>= 6.1, < 9)
77
connection_pool (>= 2.2, < 4)

lib/parse/agent/mcp_rack_app.rb

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1183,10 +1183,21 @@ def principal_fingerprint(agent, env)
11831183
# Wire format for each SSE event (note: trailing blank line is required
11841184
# by the SSE spec):
11851185
#
1186-
# event: progress\n
1186+
# event: message\n
11871187
# data: <json>\n
11881188
# \n
11891189
#
1190+
# EVERY frame — progress notifications, list-changed notifications,
1191+
# and the final JSON-RPC response alike — carries the event name
1192+
# `message`. MCP Streamable HTTP defines exactly one SSE event type
1193+
# for JSON-RPC traffic; clients discriminate by inspecting the
1194+
# envelope (`method` present => notification, `id` + `result`/`error`
1195+
# => response), NOT by the SSE event name. Earlier releases emitted
1196+
# `event: progress` and `event: response`, which real MCP clients
1197+
# silently discard — they match only the default `message` type — so
1198+
# the final response never arrived and the call appeared to hang.
1199+
# Do not reintroduce custom event names.
1200+
#
11901201
# @api private
11911202
class SSEBody
11921203
# Sentinel pushed to the queue when the worker is done.
@@ -1546,6 +1557,9 @@ def start_worker
15461557
# The `total` field is omitted (rather than nil) so the wire
15471558
# shape matches the spec's optional-field convention.
15481559
#
1560+
# Emitted as `event: message` — see the SSEBody class docs. The
1561+
# payload's `method` is what marks it as progress.
1562+
#
15491563
# @param elapsed [Float] seconds elapsed since the stream started.
15501564
# @return [String] SSE event string (includes trailing blank line).
15511565
def build_progress_event(elapsed)
@@ -1557,15 +1571,14 @@ def build_progress_event(elapsed)
15571571
"progress" => elapsed,
15581572
},
15591573
})
1560-
"event: progress\ndata: #{data}\n\n"
1574+
"event: message\ndata: #{data}\n\n"
15611575
end
15621576

15631577
# Format a `notifications/tools/list_changed` or
15641578
# `notifications/prompts/list_changed` SSE event. Both
15651579
# notifications have no `params` — the wire shape is just the
1566-
# JSON-RPC envelope with `method` set. SSE event name is
1567-
# "message" since this is not a progress notification (the
1568-
# progress event name is reserved for progress notifications).
1580+
# JSON-RPC envelope with `method` set. Emitted as `event: message`,
1581+
# like every other frame on this stream.
15691582
#
15701583
# @param method [String] full MCP method string.
15711584
# @return [String] SSE event string (includes trailing blank line).
@@ -1599,7 +1612,7 @@ def build_tool_progress_event(progress, total, message)
15991612
"method" => "notifications/progress",
16001613
"params" => params,
16011614
})
1602-
"event: progress\ndata: #{data}\n\n"
1615+
"event: message\ndata: #{data}\n\n"
16031616
end
16041617

16051618
# Build the callback the dispatcher block passes into
@@ -1631,12 +1644,17 @@ def build_progress_callback
16311644
end
16321645
end
16331646

1634-
# Format the final `response` SSE event.
1647+
# Format the final JSON-RPC response SSE event.
1648+
#
1649+
# Emitted as `event: message` (NOT `event: response`) — an MCP
1650+
# client matching only the default `message` type would otherwise
1651+
# discard the response and block until its own timeout. The
1652+
# envelope's `id` + `result`/`error` is what marks it final.
16351653
#
16361654
# @param body [Hash] JSON-RPC response envelope.
16371655
# @return [String] SSE event string (includes trailing blank line).
16381656
def build_response_event(body)
1639-
"event: response\ndata: #{JSON.generate(body)}\n\n"
1657+
"event: message\ndata: #{JSON.generate(body)}\n\n"
16401658
end
16411659

16421660
# Build an internal-error JSON-RPC envelope (id may be nil at this layer).
@@ -1755,9 +1773,9 @@ def start_heartbeat
17551773
end
17561774
end
17571775

1758-
# SSE wire form for a server→client notification. Event name "message"
1759-
# (not "progress"/"response", which are reserved for the request-scoped
1760-
# SSE path).
1776+
# SSE wire form for a server→client notification. Event name
1777+
# "message" — the single event type MCP Streamable HTTP defines for
1778+
# JSON-RPC traffic, matching the request-scoped SSE path.
17611779
def format_event(notification)
17621780
"event: message\ndata: #{JSON.generate(notification)}\n\n"
17631781
end

lib/parse/stack/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@ module Parse
66
# The Parse Server SDK for Ruby
77
module Stack
88
# The current version.
9-
VERSION = "5.5.5"
9+
VERSION = "5.5.6"
1010
end
1111
end

test/lib/parse/agent/mcp_sse_e2e_test.rb

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,34 @@
3939
# Shared SSE parse helper
4040
# ---------------------------------------------------------------------------
4141
module SSETestHelpers
42-
# Parse raw SSE text (may span many chunks) into [{event:, data:}] hashes.
42+
# Parse raw SSE text (may span many chunks) into [{event:, data:, kind:}].
43+
#
44+
# `event:` is always the wire-mandated "message"; `kind:` is the
45+
# payload-derived classification (see #sse_kind) that tests assert on,
46+
# mirroring how a real MCP client discriminates frames.
4347
def parse_sse(raw)
4448
events = []
4549
raw.scan(/event:\s*(\S+)\r?\ndata:\s*(.+?)(?=\r?\n\r?\n|\z)/m) do |event, data|
46-
events << { event: event.strip, data: data.strip }
50+
clean = data.strip
51+
events << { event: event.strip, data: clean, kind: sse_kind(clean) }
4752
end
4853
events
4954
end
5055

56+
# Classify an SSE frame the way an MCP client does — from the envelope.
57+
def sse_kind(data)
58+
payload = begin
59+
JSON.parse(data)
60+
rescue StandardError
61+
nil
62+
end
63+
return :unknown unless payload.is_a?(Hash)
64+
return :progress if payload["method"] == "notifications/progress"
65+
return :notification if payload.key?("method")
66+
return :response if payload.key?("id") && (payload.key?("result") || payload.key?("error"))
67+
:unknown
68+
end
69+
5170
# Collect all streamed chunks from a Net::HTTP response into a single String.
5271
def collect_sse_body(response)
5372
buf = +""
@@ -261,7 +280,7 @@ def test_at_least_two_progress_events_arrive_for_slow_tool
261280
@@dispatch_delay = 0.6
262281

263282
events, _headers, _raw = sse_post(tools_call_body)
264-
progress_events = events.select { |e| e[:event] == "progress" }
283+
progress_events = events.select { |e| e[:kind] == :progress }
265284

266285
assert progress_events.size >= 2,
267286
"Expected >=2 progress events for 0.6s tool; got #{progress_events.size}"
@@ -273,7 +292,7 @@ def test_progress_events_have_jsonrpc_notification_shape
273292
@@dispatch_delay = 0.6
274293

275294
events, _headers, _raw = sse_post(tools_call_body)
276-
progress_events = events.select { |e| e[:event] == "progress" }
295+
progress_events = events.select { |e| e[:kind] == :progress }
277296

278297
assert progress_events.size >= 1, "Need at least one progress event to check shape"
279298

@@ -300,7 +319,7 @@ def test_progress_token_from_client_params_echoed_in_events
300319
@@dispatch_delay = 0.6
301320

302321
events, _headers, _raw = sse_post(tools_call_body(progress_token: token))
303-
progress_events = events.select { |e| e[:event] == "progress" }
322+
progress_events = events.select { |e| e[:kind] == :progress }
304323

305324
assert progress_events.size >= 1, "Need progress events to verify token"
306325

@@ -322,7 +341,7 @@ def test_exactly_one_response_event_in_stream
322341
@@dispatch_delay = 0.3
323342

324343
events, _headers, _raw = sse_post(tools_call_body(id: 42))
325-
response_events = events.select { |e| e[:event] == "response" }
344+
response_events = events.select { |e| e[:kind] == :response }
326345

327346
assert_equal 1, response_events.size,
328347
"Expected exactly one response event, got #{response_events.size}"
@@ -332,7 +351,7 @@ def test_response_event_contains_valid_jsonrpc_envelope
332351
@@dispatch_delay = 0.15
333352

334353
events, _headers, _raw = sse_post(tools_call_body(id: 99))
335-
response_event = events.find { |e| e[:event] == "response" }
354+
response_event = events.find { |e| e[:kind] == :response }
336355
refute_nil response_event, "No response event found in SSE stream"
337356

338357
data = JSON.parse(response_event[:data])
@@ -353,7 +372,7 @@ def test_response_event_matches_plain_json_result
353372
@@dispatch_delay = 0
354373

355374
events, _headers, _raw = sse_post(tools_call_body(id: 7))
356-
response_event = events.find { |e| e[:event] == "response" }
375+
response_event = events.find { |e| e[:kind] == :response }
357376
refute_nil response_event
358377

359378
data = JSON.parse(response_event[:data])
@@ -520,8 +539,8 @@ def test_multiple_concurrent_sse_connections
520539
Thread.new do
521540
events, _h, _r = sse_post(tools_call_body(id: 100 + i))
522541
results[i] = {
523-
progress: events.count { |e| e[:event] == "progress" },
524-
response: events.count { |e| e[:event] == "response" },
542+
progress: events.count { |e| e[:kind] == :progress },
543+
response: events.count { |e| e[:kind] == :response },
525544
}
526545
end
527546
end
@@ -545,8 +564,10 @@ def test_response_event_is_last_in_stream
545564

546565
refute events.empty?, "Should have received at least one event"
547566
last_event = events.last
548-
assert_equal "response", last_event[:event],
549-
"Final event must be 'response', got '#{last_event[:event]}'"
567+
assert_equal :response, last_event[:kind],
568+
"Final frame must carry the JSON-RPC response envelope, got #{last_event[:kind].inspect}"
569+
assert_equal "message", last_event[:event],
570+
"Final frame must use the wire event name 'message', got '#{last_event[:event]}'"
550571
end
551572

552573
# ---------------------------------------------------------------------------
@@ -558,8 +579,8 @@ def test_progress_events_precede_response_event
558579

559580
events, _headers, _raw = sse_post(tools_call_body)
560581

561-
progress_indices = events.each_index.select { |i| events[i][:event] == "progress" }
562-
response_index = events.each_index.find { |i| events[i][:event] == "response" }
582+
progress_indices = events.each_index.select { |i| events[i][:kind] == :progress }
583+
response_index = events.each_index.find { |i| events[i][:kind] == :response }
563584

564585
refute_nil response_index, "No response event found"
565586
assert progress_indices.size >= 1, "Expected at least one progress event before response"
@@ -580,7 +601,7 @@ def test_auto_generated_progress_token_is_non_empty_string
580601
# Do NOT supply a progressToken in the request
581602
events, _headers, _raw = sse_post(tools_call_body)
582603

583-
progress_events = events.select { |e| e[:event] == "progress" }
604+
progress_events = events.select { |e| e[:kind] == :progress }
584605
assert progress_events.size >= 1, "Need at least one progress event"
585606

586607
token = JSON.parse(progress_events.first[:data]).dig("params", "progressToken")

0 commit comments

Comments
 (0)