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.
- checksums.yaml +4 -4
- data/README.md +234 -7
- data/lib/mcp_client/auth/oauth_provider.rb +553 -50
- data/lib/mcp_client/auth.rb +47 -9
- data/lib/mcp_client/client.rb +613 -172
- data/lib/mcp_client/elicitation_validator.rb +43 -0
- data/lib/mcp_client/errors.rb +44 -1
- data/lib/mcp_client/http_transport_base.rb +232 -38
- data/lib/mcp_client/json_rpc_common.rb +140 -12
- data/lib/mcp_client/oauth_client.rb +8 -3
- data/lib/mcp_client/prompt.rb +17 -2
- data/lib/mcp_client/resource.rb +15 -2
- data/lib/mcp_client/resource_content.rb +8 -3
- data/lib/mcp_client/resource_link.rb +16 -3
- data/lib/mcp_client/resource_template.rb +15 -2
- data/lib/mcp_client/root.rb +61 -7
- data/lib/mcp_client/schema_validator.rb +285 -0
- data/lib/mcp_client/server_base.rb +139 -0
- data/lib/mcp_client/server_http/json_rpc_transport.rb +2 -1
- data/lib/mcp_client/server_http.rb +45 -29
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +67 -9
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +11 -0
- data/lib/mcp_client/server_sse/sse_parser.rb +51 -11
- data/lib/mcp_client/server_sse.rb +98 -57
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +42 -9
- data/lib/mcp_client/server_stdio.rb +216 -37
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +149 -23
- data/lib/mcp_client/server_streamable_http.rb +249 -107
- data/lib/mcp_client/task.rb +93 -61
- data/lib/mcp_client/tool.rb +63 -10
- data/lib/mcp_client/version.rb +6 -1
- data/lib/mcp_client.rb +1 -0
- metadata +19 -4
|
@@ -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
|
|
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
|
-
# @
|
|
53
|
-
# @
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
|
66
|
-
current_event = { type:
|
|
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
|
|
145
|
+
events << current_event if sse_event_present?(current_event)
|
|
146
|
+
[events, retry_ms]
|
|
147
|
+
end
|
|
78
148
|
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|