ruby-mcp-client 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,10 +22,11 @@ module MCPClient
22
22
 
23
23
  # Parse a Streamable HTTP JSON-RPC response (JSON or SSE format)
24
24
  # @param response [Faraday::Response] the HTTP response
25
+ # @param request [Hash, nil] the originating JSON-RPC request, used to match the response by id
25
26
  # @return [Hash] the parsed result
26
27
  # @raise [MCPClient::Errors::TransportError] if parsing fails
27
28
  # @raise [MCPClient::Errors::ServerError] if the response contains an error
28
- def parse_response(response)
29
+ def parse_response(response, request = nil)
29
30
  body = response.body
30
31
  content_type = response.headers['content-type'] || response.headers['Content-Type'] || ''
31
32
  content_encoding = response.headers['content-encoding'] || response.headers['Content-Encoding'] || ''
@@ -36,7 +37,7 @@ module MCPClient
36
37
  # Determine response format based on Content-Type header per MCP 2025 spec
37
38
  data = if content_type.include?('text/event-stream')
38
39
  # Parse SSE-formatted response for streaming
39
- parse_sse_response(body)
40
+ parse_sse_response(body, request && request['id'])
40
41
  else
41
42
  # Parse regular JSON response (default for Streamable HTTP)
42
43
  JSON.parse(body)
@@ -47,50 +48,175 @@ module MCPClient
47
48
  raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
48
49
  end
49
50
 
50
- # Parse Server-Sent Event formatted response with event ID tracking
51
+ # Parse a Server-Sent Event formatted response body.
52
+ #
53
+ # Per MCP 2025-11-25, the server MAY send JSON-RPC requests and
54
+ # notifications on the POST response stream before the response, and MAY
55
+ # send priming events carrying only an event id. Every interleaved server
56
+ # message is dispatched exactly like on the GET events stream; the
57
+ # JSON-RPC response matching the originating request id is returned.
58
+ #
51
59
  # @param sse_body [String] the SSE formatted response body
52
- # @return [Hash] the parsed JSON data
53
- # @raise [MCPClient::Errors::TransportError] if no data found in SSE response
54
- def parse_sse_response(sse_body)
55
- # Extract JSON data from SSE format, processing events separately
56
- # SSE format: event: message\nid: 123\ndata: {...}\n\n
60
+ # @param request_id [Integer, String, nil] id of the originating request
61
+ # @return [Hash] the parsed JSON-RPC response
62
+ # @raise [MCPClient::Errors::TransportError] if no response is found
63
+ def parse_sse_response(sse_body, request_id = nil)
64
+ events, retry_ms = extract_sse_events(sse_body)
65
+
66
+ raise MCPClient::Errors::TransportError, 'No data found in SSE response' if events.empty?
67
+
68
+ responses, saw_invalid_json = route_sse_events(events)
69
+ matched = select_sse_response(responses, request_id)
70
+ return matched if matched
71
+
72
+ if saw_invalid_json
73
+ raise MCPClient::Errors::TransportError,
74
+ 'Invalid JSON response from server: SSE stream contained no valid JSON-RPC response'
75
+ end
76
+
77
+ resume_or_fail(events, request_id, retry_ms)
78
+ end
79
+
80
+ # SEP-1699 polling pattern: the server MAY close the POST stream before
81
+ # delivering the response. When a cursor was received, resume via HTTP
82
+ # GET with Last-Event-ID instead of re-POSTing the (possibly
83
+ # non-idempotent) request.
84
+ # @param events [Array<Hash>] parsed SSE events
85
+ # @param request_id [Integer, String, nil] id of the originating request
86
+ # @param retry_ms [Integer, nil] retry directive received on THIS stream
87
+ # @return [Hash] the replayed JSON-RPC response
88
+ # @raise [MCPClient::Errors::ServerError] when resumption fails
89
+ # @raise [MCPClient::Errors::TransportError] when no cursor was received
90
+ def resume_or_fail(events, request_id, retry_ms = nil)
91
+ cursor = events.reverse.find { |e| e[:id] && !e[:id].empty? }&.dig(:id)
92
+ if request_id && cursor
93
+ # Resume with THIS stream's cursor and retry directive (both are
94
+ # per-stream), not the shared @last_event_id / @sse_retry_ms which a
95
+ # concurrent stream may have moved between parsing and resumption.
96
+ resumed = resume_response_via_get(request_id, cursor, retry_ms)
97
+ return resumed if resumed
98
+
99
+ # Non-retryable: the request may already be executing server-side,
100
+ # so a blind re-POST could run a non-idempotent operation twice.
101
+ raise MCPClient::Errors::ServerError,
102
+ 'SSE stream closed before delivering the response and resumption via GET failed'
103
+ end
104
+
105
+ raise MCPClient::Errors::TransportError, 'No JSON-RPC response found in SSE response'
106
+ end
107
+
108
+ # Split an SSE body into events. An event without an explicit `event:`
109
+ # field has the default type "message" per the SSE specification; events
110
+ # carrying only an id (priming events) are kept so their id is tracked.
111
+ # @param sse_body [String] the SSE formatted response body
112
+ # @return [Array(Array<Hash>, Integer, nil)] parsed events and the last
113
+ # retry directive (ms) received on this stream, if any
114
+ def extract_sse_events(sse_body)
57
115
  events = []
58
- current_event = { type: nil, data_lines: [], id: nil }
116
+ retry_ms = nil
117
+ current_event = { type: 'message', data_lines: [], id: nil }
59
118
 
60
119
  sse_body.lines.each do |line|
61
120
  line = line.strip
62
121
 
63
122
  if line.empty?
64
123
  # Empty line marks end of an event
65
- events << current_event.dup if current_event[:type] && !current_event[:data_lines].empty?
66
- current_event = { type: nil, data_lines: [], id: nil }
124
+ events << current_event.dup if sse_event_present?(current_event)
125
+ current_event = { type: 'message', data_lines: [], id: nil }
67
126
  elsif line.start_with?('event:')
68
127
  current_event[:type] = line.sub(/^event:\s*/, '').strip
69
128
  elsif line.start_with?('data:')
70
129
  current_event[:data_lines] << line.sub(/^data:\s*/, '').strip
71
130
  elsif line.start_with?('id:')
72
131
  current_event[:id] = line.sub(/^id:\s*/, '').strip
132
+ elsif line.start_with?('retry:')
133
+ # SEP-1699: the client MUST respect the server's retry directive.
134
+ # Track it locally for this stream's resumption; the shared ivar is
135
+ # only a hint for the general events loop.
136
+ raw = line.sub(/^retry:\s*/, '').strip
137
+ if raw.match?(/\A\d+\z/)
138
+ retry_ms = raw.to_i
139
+ @sse_retry_ms = retry_ms
140
+ end
73
141
  end
74
142
  end
75
143
 
76
144
  # Handle last event if no trailing empty line
77
- events << current_event if current_event[:type] && !current_event[:data_lines].empty?
145
+ events << current_event if sse_event_present?(current_event)
146
+ [events, retry_ms]
147
+ end
78
148
 
79
- # Find the first 'message' event which contains the JSON-RPC response
80
- message_event = events.find { |e| e[:type] == 'message' }
149
+ # @param event [Hash] a parsed SSE event
150
+ # @return [Boolean] whether the event carries any data or id
151
+ def sse_event_present?(event)
152
+ (event[:id] && !event[:id].empty?) || !event[:data_lines].empty?
153
+ end
154
+
155
+ # Track event ids for resumability, dispatch interleaved server messages
156
+ # (requests, notifications, pings) and collect response candidates.
157
+ # @param events [Array<Hash>] parsed SSE events
158
+ # @return [Array(Array<Hash>, Boolean)] response candidates and whether invalid JSON was seen
159
+ def route_sse_events(events)
160
+ responses = []
161
+ saw_invalid_json = false
162
+
163
+ events.each do |event|
164
+ if event[:id] && !event[:id].empty?
165
+ @mutex.synchronize { @last_event_id = event[:id] }
166
+ @logger.debug("Tracking event ID for resumability: #{event[:id]}")
167
+ end
168
+ next unless event[:type] == 'message'
169
+
170
+ message = parse_sse_event_data(event[:data_lines].join("\n"))
171
+ saw_invalid_json = true if message == :invalid
172
+ next unless message.is_a?(Hash)
173
+
174
+ if message['method']
175
+ dispatch_server_message(message)
176
+ else
177
+ responses << message
178
+ end
179
+ end
81
180
 
82
- raise MCPClient::Errors::TransportError, 'No data found in SSE response' unless message_event
83
- raise MCPClient::Errors::TransportError, 'No data found in message event' if message_event[:data_lines].empty?
181
+ [responses, saw_invalid_json]
182
+ end
183
+
184
+ # Parse the data payload of a single SSE event.
185
+ # @param json_data [String] the joined data lines
186
+ # @return [Hash, Symbol, nil] the parsed message, :invalid, or nil for empty/non-object data
187
+ def parse_sse_event_data(json_data)
188
+ return nil if json_data.empty?
189
+
190
+ message = JSON.parse(json_data)
191
+ return message if message.is_a?(Hash)
192
+
193
+ @logger.warn("Skipping non-object JSON-RPC message in SSE event: #{message.inspect}")
194
+ nil
195
+ rescue JSON::ParserError => e
196
+ @logger.warn("Skipping invalid JSON in SSE event: #{e.message}")
197
+ :invalid
198
+ end
84
199
 
85
- # Track the event ID for resumability
86
- if message_event[:id] && !message_event[:id].empty?
87
- @last_event_id = message_event[:id]
88
- @logger.debug("Tracking event ID for resumability: #{message_event[:id]}")
200
+ # Choose the JSON-RPC response answering the originating request.
201
+ # @param responses [Array<Hash>] response candidates from the stream
202
+ # @param request_id [Integer, String, nil] id of the originating request
203
+ # @return [Hash, nil] the selected response, if any
204
+ def select_sse_response(responses, request_id)
205
+ matched = if request_id.nil?
206
+ responses.first
207
+ else
208
+ responses.find { |msg| msg['id'] == request_id || msg['id'].to_s == request_id.to_s }
209
+ end
210
+
211
+ if matched.nil? && responses.length == 1
212
+ matched = responses.first
213
+ @logger.warn(
214
+ "SSE response id #{matched['id'].inspect} does not match request id #{request_id.inspect}; " \
215
+ 'accepting the only response on the stream'
216
+ )
89
217
  end
90
218
 
91
- # Join multiline data fields according to SSE spec
92
- json_data = message_event[:data_lines].join("\n")
93
- JSON.parse(json_data)
219
+ matched
94
220
  end
95
221
  end
96
222
  end