mcp 0.22.0 → 0.24.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
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "client/elicitation"
3
4
  require_relative "client/oauth"
4
5
  require_relative "client/stdio"
5
6
  require_relative "client/http"
6
7
  require_relative "client/paginated_result"
7
8
  require_relative "client/tool"
9
+ require_relative "result_type"
8
10
 
9
11
  module MCP
10
12
  class Client
@@ -29,11 +31,44 @@ module MCP
29
31
  end
30
32
  end
31
33
 
34
+ # Raised inside a server-to-client request handler (registered via `on_server_request`, e.g. `on_sampling`)
35
+ # to answer the request with a specific JSON-RPC error code instead of the default internal error.
36
+ # Mirrors the TypeScript SDK's `McpError` and the Python SDK's `ErrorData` return: for example,
37
+ # the sampling spec answers a rejected request with code `-1`.
38
+ # https://modelcontextprotocol.io/specification/2025-11-25/client/sampling
39
+ class ServerRequestError < StandardError
40
+ attr_reader :code
41
+
42
+ def initialize(message, code:)
43
+ super(message)
44
+ @code = code
45
+ end
46
+ end
47
+
32
48
  # Raised when a server response fails client-side validation, e.g., a success response
33
49
  # whose `result` field is missing or has the wrong type. This is distinct from a
34
50
  # server-returned JSON-RPC error, which is raised as `ServerError`.
35
51
  class ValidationError < StandardError; end
36
52
 
53
+ # Raised when a server answers with a SEP-2322 Multi Round-Trip `input_required` result instead of
54
+ # a final result. The result is not an error on the wire: it asks the client to fulfill the server's
55
+ # `inputRequests` (a map of id => `{ "method" => ..., "params" => ... }` request objects with
56
+ # `sampling/createMessage`, `roots/list`, or `elicitation/create` shapes) and re-issue
57
+ # the original request with `inputResponses` plus the echoed opaque `requestState`.
58
+ # This SDK does not yet drive that resume loop automatically; callers can inspect `input_requests`
59
+ # and respond manually.
60
+ # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322
61
+ class InputRequiredError < StandardError
62
+ attr_reader :input_requests, :request_state, :result
63
+
64
+ def initialize(message, input_requests:, request_state: nil, result: nil)
65
+ super(message)
66
+ @input_requests = input_requests
67
+ @request_state = request_state
68
+ @result = result
69
+ end
70
+ end
71
+
37
72
  # Raised when the server responds 404 to a request containing a session ID,
38
73
  # indicating the session has expired. Inherits from `RequestHandlerError` for
39
74
  # backward compatibility with callers that rescue the generic error. Per spec,
@@ -134,7 +169,13 @@ module MCP
134
169
  )
135
170
  end
136
171
 
137
- ListToolsResult.new(tools: tools, next_cursor: result["nextCursor"], meta: result["_meta"])
172
+ ListToolsResult.new(
173
+ tools: tools,
174
+ next_cursor: result["nextCursor"],
175
+ meta: result["_meta"],
176
+ ttl_ms: result["ttlMs"],
177
+ cache_scope: result["cacheScope"],
178
+ )
138
179
  end
139
180
 
140
181
  # Returns every tool available on the server. Iterates through all pages automatically
@@ -175,6 +216,8 @@ module MCP
175
216
  resources: result["resources"] || [],
176
217
  next_cursor: result["nextCursor"],
177
218
  meta: result["_meta"],
219
+ ttl_ms: result["ttlMs"],
220
+ cache_scope: result["cacheScope"],
178
221
  )
179
222
  end
180
223
 
@@ -208,6 +251,8 @@ module MCP
208
251
  resource_templates: result["resourceTemplates"] || [],
209
252
  next_cursor: result["nextCursor"],
210
253
  meta: result["_meta"],
254
+ ttl_ms: result["ttlMs"],
255
+ cache_scope: result["cacheScope"],
211
256
  )
212
257
  end
213
258
 
@@ -241,6 +286,8 @@ module MCP
241
286
  prompts: result["prompts"] || [],
242
287
  next_cursor: result["nextCursor"],
243
288
  meta: result["_meta"],
289
+ ttl_ms: result["ttlMs"],
290
+ cache_scope: result["cacheScope"],
244
291
  )
245
292
  end
246
293
 
@@ -352,6 +399,30 @@ module MCP
352
399
  response.dig("result", "completion") || { "values" => [], "hasMore" => false }
353
400
  end
354
401
 
402
+ # Registers a handler for `elicitation/create` requests the server sends while one of
403
+ # this client's requests is in flight. The handler receives the request `params`
404
+ # (message and `requestedSchema`, string keys) and must return an `ElicitResult`-shaped Hash:
405
+ # `{ action: "accept" | "decline" | "cancel", content: { ... } }`.
406
+ #
407
+ # Requires a transport that supports server-to-client requests (e.g. `MCP::Client::HTTP`);
408
+ # pass `capabilities: { elicitation: {} }` to `connect` so the server knows it may send them.
409
+ #
410
+ # @example Accept with schema defaults applied (SEP-1034)
411
+ # client.on_elicitation do |params|
412
+ # {
413
+ # action: "accept",
414
+ # content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]),
415
+ # }
416
+ # end
417
+ # https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation
418
+ def on_elicitation(&handler)
419
+ unless transport.respond_to?(:on_server_request)
420
+ raise ArgumentError, "The transport does not support server-to-client requests"
421
+ end
422
+
423
+ transport.on_server_request(Methods::ELICITATION_CREATE, &handler)
424
+ end
425
+
355
426
  # Sends a `ping` request to the server to verify the connection is alive.
356
427
  # Per the MCP spec, the server responds with an empty result.
357
428
  #
@@ -422,9 +493,26 @@ module MCP
422
493
  raise ServerError.new(error["message"], code: error["code"], data: error["data"])
423
494
  end
424
495
 
496
+ raise_on_input_required(response)
497
+
425
498
  response
426
499
  end
427
500
 
501
+ # Recognizes a SEP-2322 `input_required` result and raises rather than returning it as if it were a final result.
502
+ # Servers on stable protocol versions never emit `resultType`, so this is a no-op for them.
503
+ def raise_on_input_required(response)
504
+ result = response.is_a?(Hash) ? response["result"] : nil
505
+ return unless result.is_a?(Hash) && result["resultType"] == ResultType::INPUT_REQUIRED
506
+
507
+ raise InputRequiredError.new(
508
+ "Server returned `input_required`; this SDK does not yet resume multi-round-trip requests (SEP-2322). " \
509
+ "Inspect `input_requests` to respond manually.",
510
+ input_requests: result["inputRequests"] || {},
511
+ request_state: result["requestState"],
512
+ result: result,
513
+ )
514
+ end
515
+
428
516
  # Generates a fresh JSON-RPC request id for an outgoing request.
429
517
  # Ids are an internal concern: the public API never accepts or exposes them, and cancellation is driven through
430
518
  # an `MCP::Cancellation` token instead.
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ # MCP-specific JSON-RPC error codes, complementing the generic codes in `JsonRpcHandler::ErrorCode`.
5
+ #
6
+ # Both constants below are introduced by the stateless lifecycle of the MCP 2026-07-28 draft (SEP-2575):
7
+ # `UNSUPPORTED_PROTOCOL_VERSION` rejects a request whose `_meta`-carried protocol version the server does not
8
+ # support (`error.data: { supported: [...], requested: "..." }`), and `MISSING_REQUIRED_CLIENT_CAPABILITY`
9
+ # rejects a request that requires a client capability the request did not declare
10
+ # (`error.data: { requiredCapabilities: {...} }`). The SDK exports the vocabulary; it does not raise
11
+ # these codes itself yet.
12
+ #
13
+ # The values come from the spec's MCP-specific error code block, which is allocated sequentially from
14
+ # `-32020` toward `-32099`. `-32020` (`HEADER_MISMATCH`, SEP-2243) precedes the two codes defined here.
15
+ #
16
+ # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
17
+ module ErrorCodes
18
+ MISSING_REQUIRED_CLIENT_CAPABILITY = -32021
19
+ UNSUPPORTED_PROTOCOL_VERSION = -32022
20
+ end
21
+ end
data/lib/mcp/methods.rb CHANGED
@@ -5,6 +5,8 @@ module MCP
5
5
  INITIALIZE = "initialize"
6
6
  PING = "ping"
7
7
  LOGGING_SET_LEVEL = "logging/setLevel"
8
+ # Sessionless capability discovery (MCP 2026-07-28 draft, SEP-2575).
9
+ SERVER_DISCOVER = "server/discover"
8
10
 
9
11
  PROMPTS_GET = "prompts/get"
10
12
  PROMPTS_LIST = "prompts/list"
@@ -47,6 +49,10 @@ module MCP
47
49
  end
48
50
 
49
51
  class << self
52
+ def notification?(method)
53
+ method.is_a?(String) && method.start_with?("notifications/")
54
+ end
55
+
50
56
  def ensure_capability!(method, capabilities)
51
57
  case method
52
58
  when PROMPTS_GET, PROMPTS_LIST
@@ -77,7 +83,7 @@ module MCP
77
83
  require_capability!(method, capabilities, :sampling)
78
84
  when ELICITATION_CREATE
79
85
  require_capability!(method, capabilities, :elicitation)
80
- when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_ROOTS_LIST_CHANGED,
86
+ when INITIALIZE, PING, SERVER_DISCOVER, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_ROOTS_LIST_CHANGED,
81
87
  NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED, NOTIFICATIONS_ELICITATION_COMPLETE
82
88
  # No specific capability required.
83
89
  end
data/lib/mcp/resource.rb CHANGED
@@ -5,6 +5,141 @@ require_relative "resource/embedded"
5
5
 
6
6
  module MCP
7
7
  class Resource
8
+ class << self
9
+ NOT_SET = Object.new
10
+
11
+ attr_reader :uri_value
12
+ attr_reader :title_value
13
+ attr_reader :description_value
14
+ attr_reader :icons_value
15
+ attr_reader :mime_type_value
16
+ attr_reader :annotations_value
17
+ attr_reader :size_value
18
+ attr_reader :meta_value
19
+
20
+ def contents(server_context: nil)
21
+ raise NotImplementedError, "Subclasses must implement contents"
22
+ end
23
+
24
+ def to_h
25
+ {
26
+ uri: uri_value,
27
+ name: name_value,
28
+ title: title_value,
29
+ description: description_value,
30
+ icons: icons_value&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) },
31
+ mimeType: mime_type_value,
32
+ annotations: annotations_value&.to_h,
33
+ size: size_value,
34
+ _meta: meta_value,
35
+ }.compact
36
+ end
37
+
38
+ def inherited(subclass)
39
+ super
40
+ subclass.instance_variable_set(:@uri_value, nil)
41
+ subclass.instance_variable_set(:@name_value, nil)
42
+ subclass.instance_variable_set(:@title_value, nil)
43
+ subclass.instance_variable_set(:@description_value, nil)
44
+ subclass.instance_variable_set(:@icons_value, nil)
45
+ subclass.instance_variable_set(:@mime_type_value, nil)
46
+ subclass.instance_variable_set(:@annotations_value, nil)
47
+ subclass.instance_variable_set(:@size_value, nil)
48
+ subclass.instance_variable_set(:@meta_value, nil)
49
+ end
50
+
51
+ def uri(value = NOT_SET)
52
+ if value == NOT_SET
53
+ @uri_value
54
+ else
55
+ @uri_value = value
56
+ end
57
+ end
58
+
59
+ def resource_name(value = NOT_SET)
60
+ if value == NOT_SET
61
+ name_value
62
+ else
63
+ @name_value = value
64
+ end
65
+ end
66
+
67
+ def name_value
68
+ @name_value || (name.nil? ? nil : StringUtils.handle_from_class_name(name))
69
+ end
70
+
71
+ def title(value = NOT_SET)
72
+ if value == NOT_SET
73
+ @title_value
74
+ else
75
+ @title_value = value
76
+ end
77
+ end
78
+
79
+ def description(value = NOT_SET)
80
+ if value == NOT_SET
81
+ @description_value
82
+ else
83
+ @description_value = value
84
+ end
85
+ end
86
+
87
+ def icons(value = NOT_SET)
88
+ if value == NOT_SET
89
+ @icons_value
90
+ else
91
+ @icons_value = value
92
+ end
93
+ end
94
+
95
+ def mime_type(value = NOT_SET)
96
+ if value == NOT_SET
97
+ @mime_type_value
98
+ else
99
+ @mime_type_value = value
100
+ end
101
+ end
102
+
103
+ def annotations(value = NOT_SET)
104
+ if value == NOT_SET
105
+ @annotations_value
106
+ else
107
+ @annotations_value = value.is_a?(Annotations) ? value : Annotations.new(**value)
108
+ end
109
+ end
110
+
111
+ def size(value = NOT_SET)
112
+ if value == NOT_SET
113
+ @size_value
114
+ else
115
+ @size_value = value
116
+ end
117
+ end
118
+
119
+ def meta(value = NOT_SET)
120
+ if value == NOT_SET
121
+ @meta_value
122
+ else
123
+ @meta_value = value
124
+ end
125
+ end
126
+
127
+ def define(uri: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil, &block)
128
+ Class.new(self) do
129
+ uri uri
130
+ resource_name name
131
+ title title
132
+ description description
133
+ icons icons
134
+ mime_type mime_type
135
+ self.annotations(annotations) if annotations
136
+ size size
137
+ meta meta
138
+ define_singleton_method(:contents, &block) if block
139
+ end
140
+ end
141
+ end
142
+
8
143
  attr_reader :uri, :name, :title, :description, :icons, :mime_type, :annotations, :size, :meta
9
144
 
10
145
  def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil)
@@ -2,6 +2,155 @@
2
2
 
3
3
  module MCP
4
4
  class ResourceTemplate
5
+ class << self
6
+ NOT_SET = Object.new
7
+
8
+ # Applied after `Regexp.escape`, which turns `{` and `}` into `\{` and `\}`.
9
+ # Variable names are restricted to valid Regexp named-group names,
10
+ # so RFC 6570 operator expressions (e.g. `{?query}`) stay literal and never match.
11
+ VARIABLE_PATTERN = /\\\{([A-Za-z_]\w*)\\\}/
12
+
13
+ attr_reader :uri_template_value
14
+ attr_reader :title_value
15
+ attr_reader :description_value
16
+ attr_reader :icons_value
17
+ attr_reader :mime_type_value
18
+ attr_reader :annotations_value
19
+ attr_reader :meta_value
20
+
21
+ def contents(server_context: nil, **params)
22
+ raise NotImplementedError, "Subclasses must implement contents"
23
+ end
24
+
25
+ def to_h
26
+ {
27
+ uriTemplate: uri_template_value,
28
+ name: name_value,
29
+ title: title_value,
30
+ description: description_value,
31
+ icons: icons_value&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) },
32
+ mimeType: mime_type_value,
33
+ annotations: annotations_value&.to_h,
34
+ _meta: meta_value,
35
+ }.compact
36
+ end
37
+
38
+ def inherited(subclass)
39
+ super
40
+ subclass.instance_variable_set(:@uri_template_value, nil)
41
+ subclass.instance_variable_set(:@uri_template_pattern, nil)
42
+ subclass.instance_variable_set(:@name_value, nil)
43
+ subclass.instance_variable_set(:@title_value, nil)
44
+ subclass.instance_variable_set(:@description_value, nil)
45
+ subclass.instance_variable_set(:@icons_value, nil)
46
+ subclass.instance_variable_set(:@mime_type_value, nil)
47
+ subclass.instance_variable_set(:@annotations_value, nil)
48
+ subclass.instance_variable_set(:@meta_value, nil)
49
+ end
50
+
51
+ def uri_template(value = NOT_SET)
52
+ if value == NOT_SET
53
+ @uri_template_value
54
+ else
55
+ @uri_template_pattern = nil
56
+ @uri_template_value = value
57
+ end
58
+ end
59
+
60
+ def resource_template_name(value = NOT_SET)
61
+ if value == NOT_SET
62
+ name_value
63
+ else
64
+ @name_value = value
65
+ end
66
+ end
67
+
68
+ def name_value
69
+ @name_value || (name.nil? ? nil : StringUtils.handle_from_class_name(name))
70
+ end
71
+
72
+ def title(value = NOT_SET)
73
+ if value == NOT_SET
74
+ @title_value
75
+ else
76
+ @title_value = value
77
+ end
78
+ end
79
+
80
+ def description(value = NOT_SET)
81
+ if value == NOT_SET
82
+ @description_value
83
+ else
84
+ @description_value = value
85
+ end
86
+ end
87
+
88
+ def icons(value = NOT_SET)
89
+ if value == NOT_SET
90
+ @icons_value
91
+ else
92
+ @icons_value = value
93
+ end
94
+ end
95
+
96
+ def mime_type(value = NOT_SET)
97
+ if value == NOT_SET
98
+ @mime_type_value
99
+ else
100
+ @mime_type_value = value
101
+ end
102
+ end
103
+
104
+ def annotations(value = NOT_SET)
105
+ if value == NOT_SET
106
+ @annotations_value
107
+ else
108
+ @annotations_value = value.is_a?(Annotations) ? value : Annotations.new(**value)
109
+ end
110
+ end
111
+
112
+ def meta(value = NOT_SET)
113
+ if value == NOT_SET
114
+ @meta_value
115
+ else
116
+ @meta_value = value
117
+ end
118
+ end
119
+
120
+ # Matches a concrete URI against the template's simple RFC 6570 level-1 `{var}` expressions.
121
+ # Returns a symbol-keyed Hash of variable values, or `nil` if the URI does not match.
122
+ # Variables match one or more characters excluding `/`, and values are not percent-decoded.
123
+ def match_uri(uri)
124
+ match = uri_template_pattern&.match(uri)
125
+ match&.named_captures&.transform_keys(&:to_sym)
126
+ end
127
+
128
+ def define(uri_template: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil, &block)
129
+ Class.new(self) do
130
+ uri_template uri_template
131
+ resource_template_name name
132
+ title title
133
+ description description
134
+ icons icons
135
+ mime_type mime_type
136
+ self.annotations(annotations) if annotations
137
+ meta meta
138
+ define_singleton_method(:contents, &block) if block
139
+ end
140
+ end
141
+
142
+ private
143
+
144
+ def uri_template_pattern
145
+ return if uri_template.nil?
146
+
147
+ @uri_template_pattern ||= begin
148
+ pattern = Regexp.escape(uri_template).gsub(VARIABLE_PATTERN) { "(?<#{Regexp.last_match(1)}>[^/]+)" }
149
+ Regexp.new("\\A#{pattern}\\z")
150
+ end
151
+ end
152
+ end
153
+
5
154
  attr_reader :uri_template, :name, :title, :description, :icons, :mime_type, :annotations, :meta
6
155
 
7
156
  def initialize(uri_template:, name:, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil)
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ # Values of the `resultType` result field introduced by SEP-2322 (Multi Round-Trip Requests)
5
+ # for the MCP 2026-07-28 draft.
6
+ #
7
+ # A result with `resultType: "input_required"` is not a final answer: it carries an `inputRequests` map
8
+ # of server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create` shapes) plus
9
+ # an opaque `requestState` string, and the client is expected to fulfill the requests and re-issue
10
+ # the original request with `inputResponses` and the echoed `requestState`. A missing `resultType`
11
+ # or `"complete"` is a final result.
12
+ #
13
+ # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322
14
+ module ResultType
15
+ COMPLETE = "complete"
16
+ INPUT_REQUIRED = "input_required"
17
+ end
18
+ end
@@ -9,10 +9,25 @@ module MCP
9
9
  class StdioTransport < Transport
10
10
  STATUS_INTERRUPTED = Signal.list["INT"] + 128
11
11
 
12
- def initialize(server)
12
+ # Default upper bound on a single newline-delimited frame. CRuby's `IO#gets`
13
+ # without a limit accumulates bytes until a newline arrives, so a peer that
14
+ # never emits one can grow a single String until the process is OOM-killed.
15
+ # 4 MiB is large enough for any realistic JSON-RPC frame, including
16
+ # base64-embedded images.
17
+ MAX_LINE_BYTES = 4 * 1024 * 1024
18
+
19
+ def initialize(server, max_line_bytes: MAX_LINE_BYTES)
13
20
  super(server)
14
21
  @open = false
15
22
  @session = nil
23
+ # Reject `nil` or non-positive values: `IO#gets("\n", nil)` and a negative
24
+ # limit read without an upper bound, which would silently disable the
25
+ # protection this option exists to provide.
26
+ unless max_line_bytes.is_a?(Integer) && max_line_bytes > 0
27
+ raise ArgumentError, "max_line_bytes must be a positive Integer"
28
+ end
29
+
30
+ @max_line_bytes = max_line_bytes
16
31
  $stdin.set_encoding("UTF-8")
17
32
  $stdout.set_encoding("UTF-8")
18
33
  end
@@ -20,7 +35,20 @@ module MCP
20
35
  def open
21
36
  @open = true
22
37
  @session = ServerSession.new(server: @server, transport: self)
23
- while @open && (line = $stdin.gets)
38
+ while @open
39
+ begin
40
+ line = read_line($stdin)
41
+ rescue RequestHandlerError => e
42
+ # Stop accumulating and end the connection gracefully rather than
43
+ # letting an unbounded read exhaust memory or escape as an uncaught
44
+ # backtrace. Scoped to the read so genuine request errors raised while
45
+ # handling a frame are not swallowed here.
46
+ @open = false
47
+ MCP.configuration.exception_reporter.call(e, { error: "stdio frame exceeds limit" })
48
+ break
49
+ end
50
+ break if line.nil?
51
+
24
52
  response = @session.handle_json(line.strip)
25
53
  send_response(response) if response
26
54
  end
@@ -73,7 +101,7 @@ module MCP
73
101
  raise
74
102
  end
75
103
 
76
- while @open && (line = $stdin.gets)
104
+ while @open && (line = read_line($stdin))
77
105
  begin
78
106
  parsed = JSON.parse(line.strip, symbolize_names: true)
79
107
  rescue JSON::ParserError => e
@@ -95,6 +123,26 @@ module MCP
95
123
 
96
124
  raise "Transport closed while waiting for response to #{method} request."
97
125
  end
126
+
127
+ private
128
+
129
+ # Reads one newline-delimited frame, bounded by `@max_line_bytes`. Returns
130
+ # the line (including its trailing newline) or `nil` at EOF. Raises when the
131
+ # limit is reached before a newline arrives, which signals a peer streaming
132
+ # an unbounded frame. A short final frame without a trailing newline (EOF) is
133
+ # still returned, since its length stays under the limit.
134
+ def read_line(io)
135
+ line = io.gets("\n", @max_line_bytes)
136
+ if line && !line.end_with?("\n") && line.bytesize >= @max_line_bytes
137
+ raise RequestHandlerError.new(
138
+ "stdio frame exceeds #{@max_line_bytes} bytes without a newline",
139
+ nil,
140
+ error_type: :internal_error,
141
+ )
142
+ end
143
+
144
+ line
145
+ end
98
146
  end
99
147
  end
100
148
  end