mcp 0.10.0 → 0.14.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.
@@ -92,7 +92,7 @@ module JsonRpcHandler
92
92
  end
93
93
 
94
94
  begin
95
- method = method_finder.call(method_name)
95
+ method = method_finder.call(method_name, id)
96
96
 
97
97
  if method.nil?
98
98
  return error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
@@ -117,20 +117,27 @@ module JsonRpcHandler
117
117
  end
118
118
 
119
119
  def handle_request_error(error, id, id_validation_pattern)
120
- error_type = error.respond_to?(:error_type) ? error.error_type : nil
121
-
122
- code, message = case error_type
123
- when :invalid_request then [ErrorCode::INVALID_REQUEST, "Invalid Request"]
124
- when :invalid_params then [ErrorCode::INVALID_PARAMS, "Invalid params"]
125
- when :parse_error then [ErrorCode::PARSE_ERROR, "Parse error"]
126
- when :internal_error then [ErrorCode::INTERNAL_ERROR, "Internal error"]
127
- else [ErrorCode::INTERNAL_ERROR, "Internal error"]
120
+ if error.respond_to?(:error_code) && error.error_code
121
+ code = error.error_code
122
+ message = error.message
123
+ else
124
+ error_type = error.respond_to?(:error_type) ? error.error_type : nil
125
+
126
+ code, message = case error_type
127
+ when :invalid_request then [ErrorCode::INVALID_REQUEST, "Invalid Request"]
128
+ when :invalid_params then [ErrorCode::INVALID_PARAMS, "Invalid params"]
129
+ when :parse_error then [ErrorCode::PARSE_ERROR, "Parse error"]
130
+ when :internal_error then [ErrorCode::INTERNAL_ERROR, "Internal error"]
131
+ else [ErrorCode::INTERNAL_ERROR, "Internal error"]
132
+ end
128
133
  end
129
134
 
135
+ data = error.respond_to?(:error_data) && error.error_data ? error.error_data : error.message
136
+
130
137
  error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
131
138
  code: code,
132
139
  message: message,
133
- data: error.message,
140
+ data: data,
134
141
  })
135
142
  end
136
143
 
@@ -1,24 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../methods"
4
+
3
5
  module MCP
4
6
  class Client
7
+ # TODO: HTTP GET for SSE streaming is not yet implemented.
8
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server
9
+ # TODO: Resumability and redelivery with Last-Event-ID is not yet implemented.
10
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery
5
11
  class HTTP
6
12
  ACCEPT_HEADER = "application/json, text/event-stream"
13
+ SESSION_ID_HEADER = "Mcp-Session-Id"
14
+ PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
7
15
 
8
- attr_reader :url
16
+ attr_reader :url, :session_id, :protocol_version
9
17
 
10
- def initialize(url:, headers: {})
18
+ def initialize(url:, headers: {}, &block)
11
19
  @url = url
12
20
  @headers = headers
21
+ @faraday_customizer = block
22
+ @session_id = nil
23
+ @protocol_version = nil
13
24
  end
14
25
 
26
+ # Sends a JSON-RPC request and returns the parsed response body.
27
+ # After a successful `initialize` handshake, the session ID and protocol
28
+ # version returned by the server are captured and automatically included
29
+ # on subsequent requests.
15
30
  def send_request(request:)
16
31
  method = request[:method] || request["method"]
17
32
  params = request[:params] || request["params"]
18
33
 
19
- response = client.post("", request)
20
- validate_response_content_type!(response, method, params)
21
- response.body
34
+ response = client.post("", request, session_headers)
35
+ body = parse_response_body(response, method, params)
36
+
37
+ capture_session_info(method, response, body)
38
+
39
+ body
22
40
  rescue Faraday::BadRequestError => e
23
41
  raise RequestHandlerError.new(
24
42
  "The #{method} request is invalid",
@@ -41,12 +59,25 @@ module MCP
41
59
  original_error: e,
42
60
  )
43
61
  rescue Faraday::ResourceNotFound => e
44
- raise RequestHandlerError.new(
45
- "The #{method} request is not found",
46
- { method: method, params: params },
47
- error_type: :not_found,
48
- original_error: e,
49
- )
62
+ # Per spec, 404 is the session-expired signal only when the request
63
+ # actually carried an `Mcp-Session-Id`. A 404 without a session attached
64
+ # (e.g. wrong URL or a stateless server) surfaces as a generic not-found.
65
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
66
+ if @session_id
67
+ clear_session
68
+ raise SessionExpiredError.new(
69
+ "The #{method} request is not found",
70
+ { method: method, params: params },
71
+ original_error: e,
72
+ )
73
+ else
74
+ raise RequestHandlerError.new(
75
+ "The #{method} request is not found",
76
+ { method: method, params: params },
77
+ error_type: :not_found,
78
+ original_error: e,
79
+ )
80
+ end
50
81
  rescue Faraday::UnprocessableEntityError => e
51
82
  raise RequestHandlerError.new(
52
83
  "The #{method} request is unprocessable",
@@ -63,6 +94,28 @@ module MCP
63
94
  )
64
95
  end
65
96
 
97
+ # Terminates the session by sending an HTTP DELETE to the MCP endpoint
98
+ # with the current `Mcp-Session-Id` header, and clears locally tracked
99
+ # session state afterward. No-op when no session has been established.
100
+ #
101
+ # Per spec, the server MAY respond with HTTP 405 Method Not Allowed when
102
+ # it does not support client-initiated termination, and returns 404 for
103
+ # a session it has already terminated. Both mean the session is gone —
104
+ # the desired end state. Other errors surface to the caller; local
105
+ # session state is cleared either way.
106
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
107
+ def close
108
+ return unless @session_id
109
+
110
+ begin
111
+ client.delete("", nil, session_headers)
112
+ rescue Faraday::ClientError => e
113
+ raise unless [404, 405].include?(e.response&.dig(:status))
114
+ ensure
115
+ clear_session
116
+ end
117
+ end
118
+
66
119
  private
67
120
 
68
121
  attr_reader :headers
@@ -78,9 +131,36 @@ module MCP
78
131
  headers.each do |key, value|
79
132
  faraday.headers[key] = value
80
133
  end
134
+
135
+ @faraday_customizer&.call(faraday)
81
136
  end
82
137
  end
83
138
 
139
+ # Per spec, the client MUST include `MCP-Session-Id` (when the server assigned one)
140
+ # and `MCP-Protocol-Version` on all requests after `initialize`.
141
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
142
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header
143
+ def session_headers
144
+ request_headers = {}
145
+ request_headers[SESSION_ID_HEADER] = @session_id if @session_id
146
+ request_headers[PROTOCOL_VERSION_HEADER] = @protocol_version if @protocol_version
147
+ request_headers
148
+ end
149
+
150
+ def capture_session_info(method, response, body)
151
+ return unless method.to_s == Methods::INITIALIZE
152
+
153
+ # Faraday normalizes header names to lowercase.
154
+ session_id = response.headers[SESSION_ID_HEADER.downcase]
155
+ @session_id ||= session_id unless session_id.to_s.empty?
156
+ @protocol_version ||= body.is_a?(Hash) ? body.dig("result", "protocolVersion") : nil
157
+ end
158
+
159
+ def clear_session
160
+ @session_id = nil
161
+ @protocol_version = nil
162
+ end
163
+
84
164
  def require_faraday!
85
165
  require "faraday"
86
166
  rescue LoadError
@@ -89,14 +169,56 @@ module MCP
89
169
  "See https://rubygems.org/gems/faraday for more details."
90
170
  end
91
171
 
92
- def validate_response_content_type!(response, method, params)
172
+ def require_event_stream_parser!
173
+ require "event_stream_parser"
174
+ rescue LoadError
175
+ raise LoadError, "The 'event_stream_parser' gem is required to parse SSE responses. " \
176
+ "Add it to your Gemfile: gem 'event_stream_parser', '>= 1.0'. " \
177
+ "See https://rubygems.org/gems/event_stream_parser for more details."
178
+ end
179
+
180
+ def parse_response_body(response, method, params)
181
+ # 202 Accepted is the server's ACK for a JSON-RPC notification or response; no body is expected.
182
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
183
+ return if response.status == 202
184
+
93
185
  content_type = response.headers["Content-Type"]
94
- return if content_type&.include?("application/json")
186
+
187
+ if content_type&.include?("text/event-stream")
188
+ parse_sse_response(response.body, method, params)
189
+ elsif content_type&.include?("application/json")
190
+ response.body
191
+ else
192
+ raise RequestHandlerError.new(
193
+ "Unsupported Content-Type: #{content_type.inspect}. Expected application/json or text/event-stream.",
194
+ { method: method, params: params },
195
+ error_type: :unsupported_media_type,
196
+ )
197
+ end
198
+ end
199
+
200
+ def parse_sse_response(body, method, params)
201
+ require_event_stream_parser!
202
+
203
+ json_rpc_response = nil
204
+ parser = EventStreamParser::Parser.new
205
+ parser.feed(body.to_s) do |_type, data, _id|
206
+ next if data.empty?
207
+
208
+ begin
209
+ parsed = JSON.parse(data)
210
+ json_rpc_response = parsed if parsed.is_a?(Hash) && (parsed.key?("result") || parsed.key?("error"))
211
+ rescue JSON::ParserError
212
+ next
213
+ end
214
+ end
215
+
216
+ return json_rpc_response if json_rpc_response
95
217
 
96
218
  raise RequestHandlerError.new(
97
- "Unsupported Content-Type: #{content_type.inspect}. This client only supports JSON responses.",
219
+ "No valid JSON-RPC response found in SSE stream",
98
220
  { method: method, params: params },
99
- error_type: :unsupported_media_type,
221
+ error_type: :parse_error,
100
222
  )
101
223
  end
102
224
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Client
5
+ # Result objects returned by `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`.
6
+ # Each carries the page items, an optional opaque `next_cursor` string for continuing pagination,
7
+ # and an optional `meta` hash mirroring the MCP `_meta` response field.
8
+ ListToolsResult = Struct.new(:tools, :next_cursor, :meta, keyword_init: true)
9
+ ListPromptsResult = Struct.new(:prompts, :next_cursor, :meta, keyword_init: true)
10
+ ListResourcesResult = Struct.new(:resources, :next_cursor, :meta, keyword_init: true)
11
+ ListResourceTemplatesResult = Struct.new(:resource_templates, :next_cursor, :meta, keyword_init: true)
12
+ end
13
+ end
data/lib/mcp/client.rb CHANGED
@@ -2,10 +2,47 @@
2
2
 
3
3
  require_relative "client/stdio"
4
4
  require_relative "client/http"
5
+ require_relative "client/paginated_result"
5
6
  require_relative "client/tool"
6
7
 
7
8
  module MCP
8
9
  class Client
10
+ class ServerError < StandardError
11
+ attr_reader :code, :data
12
+
13
+ def initialize(message, code:, data: nil)
14
+ super(message)
15
+ @code = code
16
+ @data = data
17
+ end
18
+ end
19
+
20
+ class RequestHandlerError < StandardError
21
+ attr_reader :error_type, :original_error, :request
22
+
23
+ def initialize(message, request, error_type: :internal_error, original_error: nil)
24
+ super(message)
25
+ @request = request
26
+ @error_type = error_type
27
+ @original_error = original_error
28
+ end
29
+ end
30
+
31
+ # Raised when a server response fails client-side validation, e.g., a success response
32
+ # whose `result` field is missing or has the wrong type. This is distinct from a
33
+ # server-returned JSON-RPC error, which is raised as `ServerError`.
34
+ class ValidationError < StandardError; end
35
+
36
+ # Raised when the server responds 404 to a request containing a session ID,
37
+ # indicating the session has expired. Inherits from `RequestHandlerError` for
38
+ # backward compatibility with callers that rescue the generic error. Per spec,
39
+ # clients MUST start a new session with a fresh `initialize` request in response.
40
+ class SessionExpiredError < RequestHandlerError
41
+ def initialize(message, request, original_error: nil)
42
+ super(message, request, error_type: :not_found, original_error: original_error)
43
+ end
44
+ end
45
+
9
46
  # Initializes a new MCP::Client instance.
10
47
  #
11
48
  # @param transport [Object] The transport object to use for communication with the server.
@@ -22,8 +59,41 @@ module MCP
22
59
  # So keeping it public
23
60
  attr_reader :transport
24
61
 
25
- # Returns the list of tools available from the server.
26
- # Each call will make a new request – the result is not cached.
62
+ # Returns a single page of tools from the server.
63
+ #
64
+ # @param cursor [String, nil] Cursor from a previous page response.
65
+ # @return [MCP::Client::ListToolsResult] Result with `tools` (Array<MCP::Client::Tool>)
66
+ # and `next_cursor` (String or nil).
67
+ #
68
+ # @example Iterate all pages
69
+ # cursor = nil
70
+ # loop do
71
+ # page = client.list_tools(cursor: cursor)
72
+ # page.tools.each { |tool| puts tool.name }
73
+ # cursor = page.next_cursor
74
+ # break unless cursor
75
+ # end
76
+ def list_tools(cursor: nil)
77
+ params = cursor ? { cursor: cursor } : nil
78
+ response = request(method: "tools/list", params: params)
79
+ result = response["result"] || {}
80
+
81
+ tools = (result["tools"] || []).map do |tool|
82
+ Tool.new(
83
+ name: tool["name"],
84
+ description: tool["description"],
85
+ input_schema: tool["inputSchema"],
86
+ )
87
+ end
88
+
89
+ ListToolsResult.new(tools: tools, next_cursor: result["nextCursor"], meta: result["_meta"])
90
+ end
91
+
92
+ # Returns every tool available on the server. Iterates through all pages automatically
93
+ # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting.
94
+ # Use {#list_tools} when you need fine-grained cursor control.
95
+ #
96
+ # Each call will make a new request - the result is not cached.
27
97
  #
28
98
  # @return [Array<MCP::Client::Tool>] An array of available tools.
29
99
  #
@@ -33,61 +103,151 @@ module MCP
33
103
  # puts tool.name
34
104
  # end
35
105
  def tools
36
- response = transport.send_request(request: {
37
- jsonrpc: JsonRpcHandler::Version::V2_0,
38
- id: request_id,
39
- method: "tools/list",
40
- })
106
+ # TODO: consider renaming to `list_all_tools`.
107
+ all_tools = []
108
+ seen = Set.new
109
+ cursor = nil
41
110
 
42
- response.dig("result", "tools")&.map do |tool|
43
- Tool.new(
44
- name: tool["name"],
45
- description: tool["description"],
46
- input_schema: tool["inputSchema"],
47
- )
48
- end || []
111
+ loop do
112
+ page = list_tools(cursor: cursor)
113
+ all_tools.concat(page.tools)
114
+ next_cursor = page.next_cursor
115
+ break if next_cursor.nil? || seen.include?(next_cursor)
116
+
117
+ seen << next_cursor
118
+ cursor = next_cursor
119
+ end
120
+
121
+ all_tools
49
122
  end
50
123
 
51
- # Returns the list of resources available from the server.
52
- # Each call will make a new request – the result is not cached.
124
+ # Returns a single page of resources from the server.
125
+ #
126
+ # @param cursor [String, nil] Cursor from a previous page response.
127
+ # @return [MCP::Client::ListResourcesResult] Result with `resources` (Array<Hash>)
128
+ # and `next_cursor` (String or nil).
129
+ def list_resources(cursor: nil)
130
+ params = cursor ? { cursor: cursor } : nil
131
+ response = request(method: "resources/list", params: params)
132
+ result = response["result"] || {}
133
+
134
+ ListResourcesResult.new(
135
+ resources: result["resources"] || [],
136
+ next_cursor: result["nextCursor"],
137
+ meta: result["_meta"],
138
+ )
139
+ end
140
+
141
+ # Returns every resource available on the server. Iterates through all pages automatically
142
+ # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting.
143
+ # Use {#list_resources} when you need fine-grained cursor control.
144
+ #
145
+ # Each call will make a new request - the result is not cached.
53
146
  #
54
147
  # @return [Array<Hash>] An array of available resources.
55
148
  def resources
56
- response = transport.send_request(request: {
57
- jsonrpc: JsonRpcHandler::Version::V2_0,
58
- id: request_id,
59
- method: "resources/list",
60
- })
149
+ # TODO: consider renaming to `list_all_resources`.
150
+ all_resources = []
151
+ seen = Set.new
152
+ cursor = nil
153
+
154
+ loop do
155
+ page = list_resources(cursor: cursor)
156
+ all_resources.concat(page.resources)
157
+ next_cursor = page.next_cursor
158
+ break if next_cursor.nil? || seen.include?(next_cursor)
61
159
 
62
- response.dig("result", "resources") || []
160
+ seen << next_cursor
161
+ cursor = next_cursor
162
+ end
163
+
164
+ all_resources
63
165
  end
64
166
 
65
- # Returns the list of resource templates available from the server.
66
- # Each call will make a new request – the result is not cached.
167
+ # Returns a single page of resource templates from the server.
168
+ #
169
+ # @param cursor [String, nil] Cursor from a previous page response.
170
+ # @return [MCP::Client::ListResourceTemplatesResult] Result with `resource_templates`
171
+ # (Array<Hash>) and `next_cursor` (String or nil).
172
+ def list_resource_templates(cursor: nil)
173
+ params = cursor ? { cursor: cursor } : nil
174
+ response = request(method: "resources/templates/list", params: params)
175
+ result = response["result"] || {}
176
+
177
+ ListResourceTemplatesResult.new(
178
+ resource_templates: result["resourceTemplates"] || [],
179
+ next_cursor: result["nextCursor"],
180
+ meta: result["_meta"],
181
+ )
182
+ end
183
+
184
+ # Returns every resource template available on the server. Iterates through all pages automatically
185
+ # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting.
186
+ # Use {#list_resource_templates} when you need fine-grained cursor control.
187
+ #
188
+ # Each call will make a new request - the result is not cached.
67
189
  #
68
190
  # @return [Array<Hash>] An array of available resource templates.
69
191
  def resource_templates
70
- response = transport.send_request(request: {
71
- jsonrpc: JsonRpcHandler::Version::V2_0,
72
- id: request_id,
73
- method: "resources/templates/list",
74
- })
192
+ # TODO: consider renaming to `list_all_resource_templates`.
193
+ all_templates = []
194
+ seen = Set.new
195
+ cursor = nil
196
+
197
+ loop do
198
+ page = list_resource_templates(cursor: cursor)
199
+ all_templates.concat(page.resource_templates)
200
+ next_cursor = page.next_cursor
201
+ break if next_cursor.nil? || seen.include?(next_cursor)
75
202
 
76
- response.dig("result", "resourceTemplates") || []
203
+ seen << next_cursor
204
+ cursor = next_cursor
205
+ end
206
+
207
+ all_templates
77
208
  end
78
209
 
79
- # Returns the list of prompts available from the server.
80
- # Each call will make a new request – the result is not cached.
210
+ # Returns a single page of prompts from the server.
211
+ #
212
+ # @param cursor [String, nil] Cursor from a previous page response.
213
+ # @return [MCP::Client::ListPromptsResult] Result with `prompts` (Array<Hash>)
214
+ # and `next_cursor` (String or nil).
215
+ def list_prompts(cursor: nil)
216
+ params = cursor ? { cursor: cursor } : nil
217
+ response = request(method: "prompts/list", params: params)
218
+ result = response["result"] || {}
219
+
220
+ ListPromptsResult.new(
221
+ prompts: result["prompts"] || [],
222
+ next_cursor: result["nextCursor"],
223
+ meta: result["_meta"],
224
+ )
225
+ end
226
+
227
+ # Returns every prompt available on the server. Iterates through all pages automatically
228
+ # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting.
229
+ # Use {#list_prompts} when you need fine-grained cursor control.
230
+ #
231
+ # Each call will make a new request - the result is not cached.
81
232
  #
82
233
  # @return [Array<Hash>] An array of available prompts.
83
234
  def prompts
84
- response = transport.send_request(request: {
85
- jsonrpc: JsonRpcHandler::Version::V2_0,
86
- id: request_id,
87
- method: "prompts/list",
88
- })
235
+ # TODO: consider renaming to `list_all_prompts`.
236
+ all_prompts = []
237
+ seen = Set.new
238
+ cursor = nil
89
239
 
90
- response.dig("result", "prompts") || []
240
+ loop do
241
+ page = list_prompts(cursor: cursor)
242
+ all_prompts.concat(page.prompts)
243
+ next_cursor = page.next_cursor
244
+ break if next_cursor.nil? || seen.include?(next_cursor)
245
+
246
+ seen << next_cursor
247
+ cursor = next_cursor
248
+ end
249
+
250
+ all_prompts
91
251
  end
92
252
 
93
253
  # Calls a tool via the transport layer and returns the full response from the server.
@@ -119,12 +279,7 @@ module MCP
119
279
  params[:_meta] = { progressToken: progress_token }
120
280
  end
121
281
 
122
- transport.send_request(request: {
123
- jsonrpc: JsonRpcHandler::Version::V2_0,
124
- id: request_id,
125
- method: "tools/call",
126
- params: params,
127
- })
282
+ request(method: "tools/call", params: params)
128
283
  end
129
284
 
130
285
  # Reads a resource from the server by URI and returns the contents.
@@ -132,12 +287,7 @@ module MCP
132
287
  # @param uri [String] The URI of the resource to read.
133
288
  # @return [Array<Hash>] An array of resource contents (text or blob).
134
289
  def read_resource(uri:)
135
- response = transport.send_request(request: {
136
- jsonrpc: JsonRpcHandler::Version::V2_0,
137
- id: request_id,
138
- method: "resources/read",
139
- params: { uri: uri },
140
- })
290
+ response = request(method: "resources/read", params: { uri: uri })
141
291
 
142
292
  response.dig("result", "contents") || []
143
293
  end
@@ -147,31 +297,68 @@ module MCP
147
297
  # @param name [String] The name of the prompt to get.
148
298
  # @return [Hash] A hash containing the prompt details.
149
299
  def get_prompt(name:)
150
- response = transport.send_request(request: {
151
- jsonrpc: JsonRpcHandler::Version::V2_0,
152
- id: request_id,
153
- method: "prompts/get",
154
- params: { name: name },
155
- })
300
+ response = request(method: "prompts/get", params: { name: name })
156
301
 
157
302
  response.fetch("result", {})
158
303
  end
159
304
 
160
- private
305
+ # Requests completion suggestions from the server for a prompt argument or resource template URI.
306
+ #
307
+ # @param ref [Hash] The reference, e.g. `{ type: "ref/prompt", name: "my_prompt" }`
308
+ # or `{ type: "ref/resource", uri: "file:///{path}" }`.
309
+ # @param argument [Hash] The argument being completed, e.g. `{ name: "language", value: "py" }`.
310
+ # @param context [Hash, nil] Optional context with previously resolved arguments.
311
+ # @return [Hash] The completion result with `"values"`, `"hasMore"`, and optionally `"total"`.
312
+ def complete(ref:, argument:, context: nil)
313
+ params = { ref: ref, argument: argument }
314
+ params[:context] = context if context
161
315
 
162
- def request_id
163
- SecureRandom.uuid
316
+ response = request(method: "completion/complete", params: params)
317
+
318
+ response.dig("result", "completion") || { "values" => [], "hasMore" => false }
164
319
  end
165
320
 
166
- class RequestHandlerError < StandardError
167
- attr_reader :error_type, :original_error, :request
321
+ # Sends a `ping` request to the server to verify the connection is alive.
322
+ # Per the MCP spec, the server responds with an empty result.
323
+ #
324
+ # @return [Hash] An empty hash on success.
325
+ # @raise [ServerError] If the server returns a JSON-RPC error.
326
+ # @raise [ValidationError] If the response `result` is missing or not a Hash.
327
+ #
328
+ # @example
329
+ # client.ping # => {}
330
+ #
331
+ # @see https://modelcontextprotocol.io/specification/latest/basic/utilities/ping
332
+ def ping
333
+ result = request(method: Methods::PING)["result"]
334
+ raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash)
168
335
 
169
- def initialize(message, request, error_type: :internal_error, original_error: nil)
170
- super(message)
171
- @request = request
172
- @error_type = error_type
173
- @original_error = original_error
336
+ result
337
+ end
338
+
339
+ private
340
+
341
+ def request(method:, params: nil)
342
+ request_body = {
343
+ jsonrpc: JsonRpcHandler::Version::V2_0,
344
+ id: request_id,
345
+ method: method,
346
+ }
347
+ request_body[:params] = params if params
348
+
349
+ response = transport.send_request(request: request_body)
350
+
351
+ # Guard with `is_a?(Hash)` because custom transports may return non-Hash values.
352
+ if response.is_a?(Hash) && response.key?("error")
353
+ error = response["error"]
354
+ raise ServerError.new(error["message"], code: error["code"], data: error["data"])
174
355
  end
356
+
357
+ response
358
+ end
359
+
360
+ def request_id
361
+ SecureRandom.uuid
175
362
  end
176
363
  end
177
364
  end