mcp 0.13.0 → 0.15.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.
data/lib/mcp/client.rb CHANGED
@@ -2,6 +2,7 @@
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
@@ -27,6 +28,21 @@ module MCP
27
28
  end
28
29
  end
29
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
+
30
46
  # Initializes a new MCP::Client instance.
31
47
  #
32
48
  # @param transport [Object] The transport object to use for communication with the server.
@@ -43,8 +59,81 @@ module MCP
43
59
  # So keeping it public
44
60
  attr_reader :transport
45
61
 
46
- # Returns the list of tools available from the server.
47
- # Each call will make a new request the result is not cached.
62
+ # The server's `InitializeResult` (protocol version, capabilities, server info,
63
+ # instructions), as reported by the transport after a successful `connect`.
64
+ # Returns `nil` before `connect`, after `close`, or when the transport does
65
+ # not expose a cached handshake result.
66
+ def server_info
67
+ transport.server_info if transport.respond_to?(:server_info)
68
+ end
69
+
70
+ # Performs the MCP `initialize` handshake by delegating to the transport
71
+ # (e.g. `MCP::Client::HTTP`, `MCP::Client::Stdio`). Returns the server's
72
+ # `InitializeResult`.
73
+ #
74
+ # When the transport does not respond to `:connect`, this is a no-op and
75
+ # returns `nil`.
76
+ #
77
+ # @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
78
+ # @param protocol_version [String, nil] Protocol version to offer.
79
+ # @param capabilities [Hash] Capabilities advertised by the client.
80
+ # @return [Hash, nil] The server's `InitializeResult`, or `nil` when the transport
81
+ # does not expose an explicit handshake.
82
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
83
+ def connect(client_info: nil, protocol_version: nil, capabilities: {})
84
+ return unless transport.respond_to?(:connect)
85
+
86
+ transport.connect(
87
+ client_info: client_info,
88
+ protocol_version: protocol_version,
89
+ capabilities: capabilities,
90
+ )
91
+ end
92
+
93
+ # Returns true once `connect` has completed the handshake on the underlying
94
+ # transport. Transports that do not expose connection state are assumed
95
+ # connected and return `true`.
96
+ def connected?
97
+ return transport.connected? if transport.respond_to?(:connected?)
98
+
99
+ true
100
+ end
101
+
102
+ # Returns a single page of tools from the server.
103
+ #
104
+ # @param cursor [String, nil] Cursor from a previous page response.
105
+ # @return [MCP::Client::ListToolsResult] Result with `tools` (Array<MCP::Client::Tool>)
106
+ # and `next_cursor` (String or nil).
107
+ #
108
+ # @example Iterate all pages
109
+ # cursor = nil
110
+ # loop do
111
+ # page = client.list_tools(cursor: cursor)
112
+ # page.tools.each { |tool| puts tool.name }
113
+ # cursor = page.next_cursor
114
+ # break unless cursor
115
+ # end
116
+ def list_tools(cursor: nil)
117
+ params = cursor ? { cursor: cursor } : nil
118
+ response = request(method: "tools/list", params: params)
119
+ result = response["result"] || {}
120
+
121
+ tools = (result["tools"] || []).map do |tool|
122
+ Tool.new(
123
+ name: tool["name"],
124
+ description: tool["description"],
125
+ input_schema: tool["inputSchema"],
126
+ )
127
+ end
128
+
129
+ ListToolsResult.new(tools: tools, next_cursor: result["nextCursor"], meta: result["_meta"])
130
+ end
131
+
132
+ # Returns every tool available on the server. Iterates through all pages automatically
133
+ # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting.
134
+ # Use {#list_tools} when you need fine-grained cursor control.
135
+ #
136
+ # Each call will make a new request - the result is not cached.
48
137
  #
49
138
  # @return [Array<MCP::Client::Tool>] An array of available tools.
50
139
  #
@@ -54,45 +143,151 @@ module MCP
54
143
  # puts tool.name
55
144
  # end
56
145
  def tools
57
- response = request(method: "tools/list")
146
+ # TODO: consider renaming to `list_all_tools`.
147
+ all_tools = []
148
+ seen = Set.new
149
+ cursor = nil
58
150
 
59
- response.dig("result", "tools")&.map do |tool|
60
- Tool.new(
61
- name: tool["name"],
62
- description: tool["description"],
63
- input_schema: tool["inputSchema"],
64
- )
65
- end || []
151
+ loop do
152
+ page = list_tools(cursor: cursor)
153
+ all_tools.concat(page.tools)
154
+ next_cursor = page.next_cursor
155
+ break if next_cursor.nil? || seen.include?(next_cursor)
156
+
157
+ seen << next_cursor
158
+ cursor = next_cursor
159
+ end
160
+
161
+ all_tools
66
162
  end
67
163
 
68
- # Returns the list of resources available from the server.
69
- # Each call will make a new request – the result is not cached.
164
+ # Returns a single page of resources from the server.
165
+ #
166
+ # @param cursor [String, nil] Cursor from a previous page response.
167
+ # @return [MCP::Client::ListResourcesResult] Result with `resources` (Array<Hash>)
168
+ # and `next_cursor` (String or nil).
169
+ def list_resources(cursor: nil)
170
+ params = cursor ? { cursor: cursor } : nil
171
+ response = request(method: "resources/list", params: params)
172
+ result = response["result"] || {}
173
+
174
+ ListResourcesResult.new(
175
+ resources: result["resources"] || [],
176
+ next_cursor: result["nextCursor"],
177
+ meta: result["_meta"],
178
+ )
179
+ end
180
+
181
+ # Returns every resource available on the server. Iterates through all pages automatically
182
+ # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting.
183
+ # Use {#list_resources} when you need fine-grained cursor control.
184
+ #
185
+ # Each call will make a new request - the result is not cached.
70
186
  #
71
187
  # @return [Array<Hash>] An array of available resources.
72
188
  def resources
73
- response = request(method: "resources/list")
189
+ # TODO: consider renaming to `list_all_resources`.
190
+ all_resources = []
191
+ seen = Set.new
192
+ cursor = nil
74
193
 
75
- response.dig("result", "resources") || []
194
+ loop do
195
+ page = list_resources(cursor: cursor)
196
+ all_resources.concat(page.resources)
197
+ next_cursor = page.next_cursor
198
+ break if next_cursor.nil? || seen.include?(next_cursor)
199
+
200
+ seen << next_cursor
201
+ cursor = next_cursor
202
+ end
203
+
204
+ all_resources
205
+ end
206
+
207
+ # Returns a single page of resource templates from the server.
208
+ #
209
+ # @param cursor [String, nil] Cursor from a previous page response.
210
+ # @return [MCP::Client::ListResourceTemplatesResult] Result with `resource_templates`
211
+ # (Array<Hash>) and `next_cursor` (String or nil).
212
+ def list_resource_templates(cursor: nil)
213
+ params = cursor ? { cursor: cursor } : nil
214
+ response = request(method: "resources/templates/list", params: params)
215
+ result = response["result"] || {}
216
+
217
+ ListResourceTemplatesResult.new(
218
+ resource_templates: result["resourceTemplates"] || [],
219
+ next_cursor: result["nextCursor"],
220
+ meta: result["_meta"],
221
+ )
76
222
  end
77
223
 
78
- # Returns the list of resource templates available from the server.
79
- # Each call will make a new request the result is not cached.
224
+ # Returns every resource template available on the server. Iterates through all pages automatically
225
+ # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting.
226
+ # Use {#list_resource_templates} when you need fine-grained cursor control.
227
+ #
228
+ # Each call will make a new request - the result is not cached.
80
229
  #
81
230
  # @return [Array<Hash>] An array of available resource templates.
82
231
  def resource_templates
83
- response = request(method: "resources/templates/list")
232
+ # TODO: consider renaming to `list_all_resource_templates`.
233
+ all_templates = []
234
+ seen = Set.new
235
+ cursor = nil
84
236
 
85
- response.dig("result", "resourceTemplates") || []
237
+ loop do
238
+ page = list_resource_templates(cursor: cursor)
239
+ all_templates.concat(page.resource_templates)
240
+ next_cursor = page.next_cursor
241
+ break if next_cursor.nil? || seen.include?(next_cursor)
242
+
243
+ seen << next_cursor
244
+ cursor = next_cursor
245
+ end
246
+
247
+ all_templates
248
+ end
249
+
250
+ # Returns a single page of prompts from the server.
251
+ #
252
+ # @param cursor [String, nil] Cursor from a previous page response.
253
+ # @return [MCP::Client::ListPromptsResult] Result with `prompts` (Array<Hash>)
254
+ # and `next_cursor` (String or nil).
255
+ def list_prompts(cursor: nil)
256
+ params = cursor ? { cursor: cursor } : nil
257
+ response = request(method: "prompts/list", params: params)
258
+ result = response["result"] || {}
259
+
260
+ ListPromptsResult.new(
261
+ prompts: result["prompts"] || [],
262
+ next_cursor: result["nextCursor"],
263
+ meta: result["_meta"],
264
+ )
86
265
  end
87
266
 
88
- # Returns the list of prompts available from the server.
89
- # Each call will make a new request the result is not cached.
267
+ # Returns every prompt available on the server. Iterates through all pages automatically
268
+ # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting.
269
+ # Use {#list_prompts} when you need fine-grained cursor control.
270
+ #
271
+ # Each call will make a new request - the result is not cached.
90
272
  #
91
273
  # @return [Array<Hash>] An array of available prompts.
92
274
  def prompts
93
- response = request(method: "prompts/list")
275
+ # TODO: consider renaming to `list_all_prompts`.
276
+ all_prompts = []
277
+ seen = Set.new
278
+ cursor = nil
279
+
280
+ loop do
281
+ page = list_prompts(cursor: cursor)
282
+ all_prompts.concat(page.prompts)
283
+ next_cursor = page.next_cursor
284
+ break if next_cursor.nil? || seen.include?(next_cursor)
285
+
286
+ seen << next_cursor
287
+ cursor = next_cursor
288
+ end
94
289
 
95
- response.dig("result", "prompts") || []
290
+ all_prompts
96
291
  end
97
292
 
98
293
  # Calls a tool via the transport layer and returns the full response from the server.
@@ -163,6 +358,24 @@ module MCP
163
358
  response.dig("result", "completion") || { "values" => [], "hasMore" => false }
164
359
  end
165
360
 
361
+ # Sends a `ping` request to the server to verify the connection is alive.
362
+ # Per the MCP spec, the server responds with an empty result.
363
+ #
364
+ # @return [Hash] An empty hash on success.
365
+ # @raise [ServerError] If the server returns a JSON-RPC error.
366
+ # @raise [ValidationError] If the response `result` is missing or not a Hash.
367
+ #
368
+ # @example
369
+ # client.ping # => {}
370
+ #
371
+ # @see https://modelcontextprotocol.io/specification/latest/basic/utilities/ping
372
+ def ping
373
+ result = request(method: Methods::PING)["result"]
374
+ raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash)
375
+
376
+ result
377
+ end
378
+
166
379
  private
167
380
 
168
381
  def request(method:, params: nil)
data/lib/mcp/methods.rb CHANGED
@@ -73,14 +73,12 @@ module MCP
73
73
  require_capability!(method, capabilities, :completions)
74
74
  when ROOTS_LIST
75
75
  require_capability!(method, capabilities, :roots)
76
- when NOTIFICATIONS_ROOTS_LIST_CHANGED
77
- require_capability!(method, capabilities, :roots)
78
- require_capability!(method, capabilities, :roots, :listChanged)
79
76
  when SAMPLING_CREATE_MESSAGE
80
77
  require_capability!(method, capabilities, :sampling)
81
78
  when ELICITATION_CREATE
82
79
  require_capability!(method, capabilities, :elicitation)
83
- when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED, NOTIFICATIONS_ELICITATION_COMPLETE
80
+ when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_ROOTS_LIST_CHANGED,
81
+ NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED, NOTIFICATIONS_ELICITATION_COMPLETE
84
82
  # No specific capability required.
85
83
  end
86
84
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Server
5
+ module Pagination
6
+ private
7
+
8
+ def cursor_from(request)
9
+ return if request.nil?
10
+
11
+ unless request.is_a?(Hash)
12
+ raise RequestHandlerError.new("Invalid params", request, error_type: :invalid_params)
13
+ end
14
+
15
+ request[:cursor]
16
+ end
17
+
18
+ def paginate(items, cursor:, page_size:, request:, &block)
19
+ start_index = 0
20
+
21
+ if cursor
22
+ unless cursor.is_a?(String)
23
+ raise RequestHandlerError.new("Invalid cursor", request, error_type: :invalid_params)
24
+ end
25
+
26
+ start_index = Integer(cursor, exception: false)
27
+ if start_index.nil? || start_index < 0 || start_index >= items.size
28
+ raise RequestHandlerError.new("Invalid cursor", request, error_type: :invalid_params)
29
+ end
30
+ end
31
+
32
+ end_index = page_size ? start_index + page_size : items.size
33
+ page = items[start_index...end_index]
34
+ page = page.map(&block) if block
35
+
36
+ result = { items: page }
37
+ result[:next_cursor] = end_index.to_s if end_index < items.size
38
+ result
39
+ end
40
+ end
41
+ end
42
+ end
@@ -54,6 +54,13 @@ module MCP
54
54
  false
55
55
  end
56
56
 
57
+ # NOTE: This signature deliberately matches the abstract `Transport#send_request` contract
58
+ # (`method, params = nil`) without the cancellation kwargs that `StreamableHTTPTransport#send_request` accepts.
59
+ # On Ruby 2.7 the project's supported minimum a method that mixes a positional `params` Hash with
60
+ # explicit keyword arguments cannot be called as `send_request(method, { ... })` - the trailing Hash would be
61
+ # auto-promoted to keyword arguments. Stdio is single-threaded and blocks on `$stdin.gets`, so nested-request
62
+ # cancellation has very limited value here regardless; servers that need cancellation propagation for nested
63
+ # server-to-client requests should use `StreamableHTTPTransport`.
57
64
  def send_request(method, params = nil)
58
65
  request_id = generate_request_id
59
66
  request = { jsonrpc: "2.0", id: request_id, method: method }
@@ -22,13 +22,14 @@ module MCP
22
22
  "Connection" => "keep-alive",
23
23
  }.freeze
24
24
 
25
- def initialize(server, stateless: false, session_idle_timeout: nil)
25
+ def initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: nil)
26
26
  super(server)
27
27
  # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
28
28
  @sessions = {}
29
29
  @mutex = Mutex.new
30
30
 
31
31
  @stateless = stateless
32
+ @enable_json_response = enable_json_response
32
33
  @session_idle_timeout = session_idle_timeout
33
34
  @pending_responses = {}
34
35
 
@@ -43,7 +44,8 @@ module MCP
43
44
  start_reaper_thread if @session_idle_timeout
44
45
  end
45
46
 
46
- REQUIRED_POST_ACCEPT_TYPES = ["application/json", "text/event-stream"].freeze
47
+ REQUIRED_POST_ACCEPT_TYPES_SSE = ["application/json", "text/event-stream"].freeze
48
+ REQUIRED_POST_ACCEPT_TYPES_JSON = ["application/json"].freeze
47
49
  REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
48
50
  STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
49
51
  SESSION_REAP_INTERVAL = 60
@@ -94,6 +96,12 @@ module MCP
94
96
 
95
97
  result = @mutex.synchronize do
96
98
  if session_id
99
+ # JSON response mode returns a single JSON object as the POST response,
100
+ # so request-scoped notifications (e.g. progress, log) cannot be delivered
101
+ # alongside it. Session-scoped standalone notifications
102
+ # (e.g. `resources/updated`, `elicitation/complete`) still flow via GET SSE.
103
+ next false if @enable_json_response && related_request_id
104
+
97
105
  # Send to specific session
98
106
  if (session = @sessions[session_id])
99
107
  stream = active_stream(session, related_request_id: related_request_id)
@@ -167,17 +175,22 @@ module MCP
167
175
  # sends the request via SSE stream, then blocks on `queue.pop`.
168
176
  # When the client POSTs a response, `handle_response` matches it by `request_id`
169
177
  # and pushes the result onto the queue, unblocking this thread.
170
- def send_request(method, params = nil, session_id: nil, related_request_id: nil)
178
+ def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil)
171
179
  if @stateless
172
180
  raise "Stateless mode does not support server-to-client requests."
173
181
  end
174
182
 
183
+ if @enable_json_response
184
+ raise "JSON response mode does not support server-to-client requests."
185
+ end
186
+
175
187
  unless session_id
176
188
  raise "session_id is required for server-to-client requests."
177
189
  end
178
190
 
179
191
  request_id = generate_request_id
180
192
  queue = Queue.new
193
+ cancel_hook = nil
181
194
 
182
195
  request = { jsonrpc: "2.0", id: request_id, method: method }
183
196
  request[:params] = params if params
@@ -217,6 +230,16 @@ module MCP
217
230
  raise "No active stream for #{method} request."
218
231
  end
219
232
 
233
+ if parent_cancellation && server_session
234
+ cancel_hook = parent_cancellation.on_cancel do |reason|
235
+ server_session.send_peer_cancellation(
236
+ nested_request_id: request_id,
237
+ related_request_id: related_request_id,
238
+ reason: reason,
239
+ )
240
+ end
241
+ end
242
+
220
243
  response = queue.pop
221
244
 
222
245
  if response.is_a?(Hash) && response.key?(:error)
@@ -227,8 +250,18 @@ module MCP
227
250
  raise "SSE session closed while waiting for #{method} response."
228
251
  end
229
252
 
253
+ if response == :cancelled
254
+ reason = @mutex.synchronize { @pending_responses.dig(request_id, :cancel_reason) }
255
+ raise MCP::CancelledError.new(
256
+ "#{method} request was cancelled",
257
+ request_id: request_id,
258
+ reason: reason,
259
+ )
260
+ end
261
+
230
262
  response
231
263
  ensure
264
+ parent_cancellation.off_cancel(cancel_hook) if cancel_hook
232
265
  if request_id
233
266
  @mutex.synchronize do
234
267
  @pending_responses.delete(request_id)
@@ -236,6 +269,24 @@ module MCP
236
269
  end
237
270
  end
238
271
 
272
+ # Unblocks a `send_request` awaiting a response when the peer is being cancelled.
273
+ # The waiting thread will see `:cancelled` on its queue and raise `MCP::CancelledError`.
274
+ #
275
+ # Race note: this is first-writer-wins on the pending-response queue. If a real response
276
+ # has already been pushed (client responded before the cancel hook fired), that response
277
+ # wins and `:cancelled` is enqueued behind it but never read - `send_request` returns
278
+ # the real response and deletes the pending entry in its `ensure` block. Conversely,
279
+ # if `:cancelled` arrives first, any later client response is silently dropped in `handle_response`
280
+ # because the pending entry has been removed.
281
+ def cancel_pending_request(request_id, reason: nil)
282
+ @mutex.synchronize do
283
+ if (pending = @pending_responses[request_id])
284
+ pending[:cancel_reason] = reason
285
+ pending[:queue].push(:cancelled)
286
+ end
287
+ end
288
+ end
289
+
239
290
  private
240
291
 
241
292
  def start_reaper_thread
@@ -269,16 +320,17 @@ module MCP
269
320
  def send_to_stream(stream, data)
270
321
  message = data.is_a?(String) ? data : data.to_json
271
322
  stream.write("data: #{message}\n\n")
272
- stream.flush if stream.respond_to?(:flush)
323
+ stream.flush
273
324
  end
274
325
 
275
326
  def send_ping_to_stream(stream)
276
327
  stream.write(": ping #{Time.now.iso8601}\n\n")
277
- stream.flush if stream.respond_to?(:flush)
328
+ stream.flush
278
329
  end
279
330
 
280
331
  def handle_post(request)
281
- accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES)
332
+ required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE
333
+ accept_error = validate_accept_header(request, required_types)
282
334
  return accept_error if accept_error
283
335
 
284
336
  content_type_error = validate_content_type(request)
@@ -296,6 +348,7 @@ module MCP
296
348
  return missing_session_id_response if !@stateless && !session_id
297
349
 
298
350
  if notification?(body)
351
+ dispatch_notification(body_string, session_id)
299
352
  handle_accepted
300
353
  elsif response?(body)
301
354
  return session_not_found_response if !@stateless && !session_exists?(session_id)
@@ -446,6 +499,22 @@ module MCP
446
499
  !body[:id] && !!body[:method]
447
500
  end
448
501
 
502
+ # Dispatches a client-originated notification (e.g. `notifications/cancelled`,
503
+ # `notifications/initialized`) through the server so it can update session state.
504
+ def dispatch_notification(body_string, session_id)
505
+ server_session = nil
506
+ if session_id && !@stateless
507
+ @mutex.synchronize do
508
+ session = @sessions[session_id]
509
+ server_session = session[:server_session] if session
510
+ end
511
+ end
512
+
513
+ dispatch_handle_json(body_string, server_session)
514
+ rescue => e
515
+ MCP.configuration.exception_reporter.call(e, { error: "Failed to dispatch notification" })
516
+ end
517
+
449
518
  def response?(body)
450
519
  !!body[:id] && !body[:method]
451
520
  end
@@ -519,10 +588,16 @@ module MCP
519
588
  end
520
589
  end
521
590
 
522
- if session_id && !@stateless
591
+ if session_id && !@stateless && !@enable_json_response
523
592
  handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
524
593
  else
525
594
  response = dispatch_handle_json(body_string, server_session)
595
+
596
+ # `Server#handle_json` returns `nil` when cancellation has suppressed the JSON-RPC response per spec.
597
+ # Mirror the notification path and ack with 202 instead of returning a 200 with a `nil` Rack body,
598
+ # which would produce an empty body the client cannot parse as JSON.
599
+ return handle_accepted if response.nil?
600
+
526
601
  [200, { "Content-Type" => "application/json" }, [response]]
527
602
  end
528
603
  end