mcp 0.10.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.
@@ -2,10 +2,22 @@
2
2
 
3
3
  module MCP
4
4
  class ServerContext
5
- def initialize(context, progress:, notification_target:)
5
+ attr_reader :cancellation
6
+
7
+ def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil)
6
8
  @context = context
7
9
  @progress = progress
8
10
  @notification_target = notification_target
11
+ @related_request_id = related_request_id
12
+ @cancellation = cancellation
13
+ end
14
+
15
+ def cancelled?
16
+ !!@cancellation&.cancelled?
17
+ end
18
+
19
+ def raise_if_cancelled!
20
+ @cancellation&.raise_if_cancelled!
9
21
  end
10
22
 
11
23
  # Reports progress for the current tool operation.
@@ -26,7 +38,74 @@ module MCP
26
38
  def notify_log_message(data:, level:, logger: nil)
27
39
  return unless @notification_target
28
40
 
29
- @notification_target.notify_log_message(data: data, level: level, logger: logger)
41
+ @notification_target.notify_log_message(data: data, level: level, logger: logger, related_request_id: @related_request_id)
42
+ end
43
+
44
+ # Sends a resource updated notification scoped to the originating session.
45
+ #
46
+ # @param uri [String] The URI of the updated resource.
47
+ def notify_resources_updated(uri:)
48
+ return unless @notification_target
49
+
50
+ @notification_target.notify_resources_updated(uri: uri)
51
+ end
52
+
53
+ # Delegates to the session so the request is scoped to the originating client.
54
+ def list_roots
55
+ if @notification_target.respond_to?(:list_roots)
56
+ @notification_target.list_roots(related_request_id: @related_request_id)
57
+ else
58
+ raise NoMethodError, "undefined method 'list_roots' for #{self}"
59
+ end
60
+ end
61
+
62
+ # Delegates to the session so the request is scoped to the originating client.
63
+ # Falls back to `@context` (via `method_missing`) when `@notification_target`
64
+ # does not support sampling.
65
+ def create_sampling_message(**kwargs)
66
+ if @notification_target.respond_to?(:create_sampling_message)
67
+ @notification_target.create_sampling_message(**kwargs, related_request_id: @related_request_id)
68
+ elsif @context.respond_to?(:create_sampling_message)
69
+ @context.create_sampling_message(**kwargs, related_request_id: @related_request_id)
70
+ else
71
+ raise NoMethodError, "undefined method 'create_sampling_message' for #{self}"
72
+ end
73
+ end
74
+
75
+ # Delegates to the session so the request is scoped to the originating client.
76
+ # Falls back to `@context` (via `method_missing`) when `@notification_target`
77
+ # does not support elicitation.
78
+ def create_form_elicitation(**kwargs)
79
+ if @notification_target.respond_to?(:create_form_elicitation)
80
+ @notification_target.create_form_elicitation(**kwargs, related_request_id: @related_request_id)
81
+ elsif @context.respond_to?(:create_form_elicitation)
82
+ @context.create_form_elicitation(**kwargs, related_request_id: @related_request_id)
83
+ else
84
+ raise NoMethodError, "undefined method 'create_form_elicitation' for #{self}"
85
+ end
86
+ end
87
+
88
+ # Delegates to the session so the request is scoped to the originating client.
89
+ # Falls back to `@context` when `@notification_target` does not support URL mode elicitation.
90
+ def create_url_elicitation(**kwargs)
91
+ if @notification_target.respond_to?(:create_url_elicitation)
92
+ @notification_target.create_url_elicitation(**kwargs, related_request_id: @related_request_id)
93
+ elsif @context.respond_to?(:create_url_elicitation)
94
+ @context.create_url_elicitation(**kwargs, related_request_id: @related_request_id)
95
+ else
96
+ raise NoMethodError, "undefined method 'create_url_elicitation' for #{self}"
97
+ end
98
+ end
99
+
100
+ # Delegates to the session so the notification is scoped to the originating client.
101
+ def notify_elicitation_complete(**kwargs)
102
+ if @notification_target.respond_to?(:notify_elicitation_complete)
103
+ @notification_target.notify_elicitation_complete(**kwargs)
104
+ elsif @context.respond_to?(:notify_elicitation_complete)
105
+ @context.notify_elicitation_complete(**kwargs)
106
+ else
107
+ raise NoMethodError, "undefined method 'notify_elicitation_complete' for #{self}"
108
+ end
30
109
  end
31
110
 
32
111
  def method_missing(name, ...)
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "cancellation"
3
4
  require_relative "methods"
4
5
 
5
6
  module MCP
@@ -13,8 +14,50 @@ module MCP
13
14
  @transport = transport
14
15
  @session_id = session_id
15
16
  @client = nil
16
- @client_capabilities = nil # TODO: Use for per-session capability validation.
17
+ @client_capabilities = nil
17
18
  @logging_message_notification = nil
19
+ @in_flight = {}
20
+ @in_flight_mutex = Mutex.new
21
+ end
22
+
23
+ # Registers a `Cancellation` token for an in-flight request.
24
+ def register_in_flight(request_id)
25
+ return if request_id.nil?
26
+
27
+ cancellation = Cancellation.new(request_id: request_id)
28
+ @in_flight_mutex.synchronize { @in_flight[request_id] = cancellation }
29
+ cancellation
30
+ end
31
+
32
+ def unregister_in_flight(request_id)
33
+ return if request_id.nil?
34
+
35
+ @in_flight_mutex.synchronize { @in_flight.delete(request_id) }
36
+ end
37
+
38
+ def lookup_in_flight(request_id)
39
+ @in_flight_mutex.synchronize { @in_flight[request_id] }
40
+ end
41
+
42
+ # Flips the `Cancellation` for a matching in-flight request received from the peer.
43
+ # Silently ignores unknown IDs per MCP spec (cancellation utilities, item 5).
44
+ def cancel_incoming(request_id:, reason: nil)
45
+ cancellation = lookup_in_flight(request_id)
46
+ cancellation&.cancel(reason: reason)
47
+ end
48
+
49
+ # Sends `notifications/cancelled` to the peer for a previously-issued request.
50
+ # Also unblocks any transport-level `send_request` waiting on a response for `request_id`.
51
+ def cancel_request(request_id:, reason: nil)
52
+ params = { requestId: request_id }
53
+ params[:reason] = reason if reason
54
+ send_to_transport(Methods::NOTIFICATIONS_CANCELLED, params)
55
+
56
+ if @transport.respond_to?(:cancel_pending_request)
57
+ @transport.cancel_pending_request(request_id, reason: reason)
58
+ end
59
+ rescue => e
60
+ MCP.configuration.exception_reporter.call(e, { notification: "cancelled", request_id: request_id })
18
61
  end
19
62
 
20
63
  def handle(request)
@@ -36,8 +79,81 @@ module MCP
36
79
  @logging_message_notification = logging_message_notification
37
80
  end
38
81
 
82
+ # Returns per-session client capabilities, falling back to global.
83
+ def client_capabilities
84
+ @client_capabilities || @server.client_capabilities
85
+ end
86
+
87
+ # Sends a `roots/list` request scoped to this session.
88
+ def list_roots(related_request_id: nil)
89
+ unless client_capabilities&.dig(:roots)
90
+ raise "Client does not support roots."
91
+ end
92
+
93
+ send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id)
94
+ end
95
+
96
+ # Sends a `sampling/createMessage` request scoped to this session.
97
+ def create_sampling_message(related_request_id: nil, **kwargs)
98
+ params = @server.build_sampling_params(client_capabilities, **kwargs)
99
+ send_to_transport_request(Methods::SAMPLING_CREATE_MESSAGE, params, related_request_id: related_request_id)
100
+ end
101
+
102
+ # Sends an `elicitation/create` request (form mode) scoped to this session.
103
+ def create_form_elicitation(message:, requested_schema:, related_request_id: nil)
104
+ unless client_capabilities&.dig(:elicitation)
105
+ raise "Client does not support elicitation. " \
106
+ "The client must declare the `elicitation` capability during initialization."
107
+ end
108
+
109
+ params = { mode: "form", message: message, requestedSchema: requested_schema }
110
+ send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id)
111
+ end
112
+
113
+ # Sends an `elicitation/create` request (URL mode) scoped to this session.
114
+ def create_url_elicitation(message:, url:, elicitation_id:, related_request_id: nil)
115
+ unless client_capabilities&.dig(:elicitation, :url)
116
+ raise "Client does not support URL mode elicitation. " \
117
+ "The client must declare the `elicitation.url` capability during initialization."
118
+ end
119
+
120
+ params = { mode: "url", message: message, url: url, elicitationId: elicitation_id }
121
+ send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id)
122
+ end
123
+
124
+ # Sends `notifications/cancelled` to the peer for a nested server-to-client request
125
+ # that was started inside a now-cancelled parent request. `related_request_id`
126
+ # is the parent request id so the notification is routed to the same stream
127
+ # (e.g. the parent's POST response stream on `StreamableHTTPTransport`) rather than
128
+ # the GET SSE stream.
129
+ def send_peer_cancellation(nested_request_id:, related_request_id: nil, reason: nil)
130
+ params = { requestId: nested_request_id }
131
+ params[:reason] = reason if reason
132
+ send_to_transport(Methods::NOTIFICATIONS_CANCELLED, params, related_request_id: related_request_id)
133
+
134
+ if @transport.respond_to?(:cancel_pending_request)
135
+ @transport.cancel_pending_request(nested_request_id, reason: reason)
136
+ end
137
+ rescue => e
138
+ MCP.configuration.exception_reporter.call(e, { notification: "cancelled", request_id: nested_request_id })
139
+ end
140
+
141
+ # Sends an elicitation complete notification scoped to this session.
142
+ def notify_elicitation_complete(elicitation_id:)
143
+ send_to_transport(Methods::NOTIFICATIONS_ELICITATION_COMPLETE, { elicitationId: elicitation_id })
144
+ rescue => e
145
+ @server.report_exception(e, notification: "elicitation_complete")
146
+ end
147
+
148
+ # Sends a resource updated notification to this session only.
149
+ def notify_resources_updated(uri:)
150
+ send_to_transport(Methods::NOTIFICATIONS_RESOURCES_UPDATED, { "uri" => uri })
151
+ rescue => e
152
+ @server.report_exception(e, notification: "resources_updated")
153
+ end
154
+
39
155
  # Sends a progress notification to this session only.
40
- def notify_progress(progress_token:, progress:, total: nil, message: nil)
156
+ def notify_progress(progress_token:, progress:, total: nil, message: nil, related_request_id: nil)
41
157
  params = {
42
158
  "progressToken" => progress_token,
43
159
  "progress" => progress,
@@ -45,35 +161,77 @@ module MCP
45
161
  "message" => message,
46
162
  }.compact
47
163
 
48
- send_to_transport(Methods::NOTIFICATIONS_PROGRESS, params)
164
+ send_to_transport(Methods::NOTIFICATIONS_PROGRESS, params, related_request_id: related_request_id)
49
165
  rescue => e
50
166
  @server.report_exception(e, notification: "progress")
51
167
  end
52
168
 
53
169
  # Sends a log message notification to this session only.
54
- def notify_log_message(data:, level:, logger: nil)
170
+ def notify_log_message(data:, level:, logger: nil, related_request_id: nil)
55
171
  effective_logging = @logging_message_notification || @server.logging_message_notification
56
172
  return unless effective_logging&.should_notify?(level)
57
173
 
58
174
  params = { "data" => data, "level" => level }
59
175
  params["logger"] = logger if logger
60
176
 
61
- send_to_transport(Methods::NOTIFICATIONS_MESSAGE, params)
177
+ send_to_transport(Methods::NOTIFICATIONS_MESSAGE, params, related_request_id: related_request_id)
62
178
  rescue => e
63
179
  @server.report_exception(e, { notification: "log_message" })
64
180
  end
65
181
 
66
182
  private
67
183
 
68
- # TODO: When Ruby 2.7 support is dropped, replace with a direct call:
69
- # `@transport.send_notification(method, params, session_id: @session_id)` and
70
- # add `**` to `Transport#send_notification` and `StdioTransport#send_notification`.
71
- def send_to_transport(method, params)
72
- if @session_id
73
- @transport.send_notification(method, params, session_id: @session_id)
184
+ # Forwards `send_notification` to the transport with only the kwargs the transport's method signature
185
+ # actually accepts. Custom transports that implement the abstract `send_notification(method, params = nil)`
186
+ # contract continue to work unchanged; bundled transports that declare `session_id:` / `related_request_id:`
187
+ # receive the session-scoped routing information.
188
+ def send_to_transport(method, params, related_request_id: nil)
189
+ kwargs = {
190
+ session_id: @session_id,
191
+ related_request_id: related_request_id,
192
+ }.compact
193
+
194
+ forward_to_transport(@transport.method(:send_notification), method, params, kwargs)
195
+ end
196
+
197
+ # Forwards `send_request` to the transport with only the kwargs the transport's method signature
198
+ # actually accepts. Custom transports that implement the abstract `send_request(method, params = nil)`
199
+ # contract continue to work; bundled transports that declare `session_id:` / `related_request_id:` /
200
+ # `parent_cancellation:` / `server_session:` receive the nested-cancellation plumbing.
201
+ # When `related_request_id` names an in-flight request, its `Cancellation` token is looked up
202
+ # so that cancelling the parent also cancels this nested server-to-client request.
203
+ def send_to_transport_request(method, params, related_request_id: nil)
204
+ parent_cancellation = related_request_id ? lookup_in_flight(related_request_id) : nil
205
+
206
+ kwargs = {
207
+ session_id: @session_id,
208
+ related_request_id: related_request_id,
209
+ parent_cancellation: parent_cancellation,
210
+ server_session: self,
211
+ }.compact
212
+
213
+ forward_to_transport(@transport.method(:send_request), method, params, kwargs)
214
+ end
215
+
216
+ # Calls `transport_method(method, params, **supported)` where `supported` contains only the keys
217
+ # the transport's method signature accepts. This keeps bundled transports (which declare the new kwargs)
218
+ # working while preserving compatibility with custom transports that implement only the abstract
219
+ # `(method, params = nil)` contract.
220
+ def forward_to_transport(transport_method, method, params, kwargs)
221
+ parameters = transport_method.parameters
222
+ accepts_keyrest = parameters.any? { |type, _| type == :keyrest }
223
+ supported = if accepts_keyrest
224
+ kwargs
74
225
  else
75
- @transport.send_notification(method, params)
226
+ allowed = parameters.filter_map { |type, name| name if type == :key || type == :keyreq }
227
+ kwargs.slice(*allowed)
76
228
  end
229
+
230
+ # Always splat `**supported` even when empty: on Ruby 2.7 the bare `transport_method.call(method, params)`
231
+ # form would let the trailing `params` Hash be auto-promoted to keyword arguments when the receiver
232
+ # accepts `**kwargs`, breaking handlers that rely on `params` arriving as a positional Hash.
233
+ # The explicit splat suppresses that conversion and is a no-op when `supported` is empty.
234
+ transport_method.call(method, params, **supported)
77
235
  end
78
236
  end
79
237
  end
@@ -5,9 +5,9 @@ module MCP
5
5
  class Response
6
6
  NOT_GIVEN = Object.new.freeze
7
7
 
8
- attr_reader :content, :structured_content
8
+ attr_reader :content, :structured_content, :meta
9
9
 
10
- def initialize(content = nil, deprecated_error = NOT_GIVEN, error: false, structured_content: nil)
10
+ def initialize(content = nil, deprecated_error = NOT_GIVEN, error: false, structured_content: nil, meta: nil)
11
11
  if deprecated_error != NOT_GIVEN
12
12
  warn("Passing `error` with the 2nd argument of `Response.new` is deprecated. Use keyword argument like `Response.new(content, error: error)` instead.", uplevel: 1)
13
13
  error = deprecated_error
@@ -16,6 +16,7 @@ module MCP
16
16
  @content = content || []
17
17
  @error = error
18
18
  @structured_content = structured_content
19
+ @meta = meta
19
20
  end
20
21
 
21
22
  def error?
@@ -23,7 +24,7 @@ module MCP
23
24
  end
24
25
 
25
26
  def to_h
26
- { content: content, isError: error?, structuredContent: @structured_content }.compact
27
+ { content: content, isError: error?, structuredContent: @structured_content, _meta: meta }.compact
27
28
  end
28
29
  end
29
30
  end
@@ -8,7 +8,7 @@ module MCP
8
8
  attr_reader :schema
9
9
 
10
10
  def initialize(schema = {})
11
- @schema = deep_transform_keys(JSON.parse(JSON.dump(schema)), &:to_sym)
11
+ @schema = JSON.parse(JSON.dump(schema), symbolize_names: true)
12
12
  @schema[:type] ||= "object"
13
13
  validate_schema!
14
14
  end
@@ -27,19 +27,6 @@ module MCP
27
27
  JSON::Validator.fully_validate(to_h, data)
28
28
  end
29
29
 
30
- def deep_transform_keys(schema, &block)
31
- case schema
32
- when Hash
33
- schema.each_with_object({}) do |(key, value), result|
34
- result[yield(key)] = deep_transform_keys(value, &block)
35
- end
36
- when Array
37
- schema.map { |e| deep_transform_keys(e, &block) }
38
- else
39
- schema
40
- end
41
- end
42
-
43
30
  def validate_schema!
44
31
  schema = to_h
45
32
  gem_path = File.realpath(Gem.loaded_specs["json-schema"].full_gem_path)
data/lib/mcp/transport.rb CHANGED
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module MCP
4
6
  class Transport
5
7
  # Initialize the transport with the server instance
6
8
  def initialize(server)
7
9
  @server = server
10
+ server.transport = self
8
11
  end
9
12
 
10
13
  # Send a response to the client
@@ -41,5 +44,16 @@ module MCP
41
44
  def send_notification(method, params = nil)
42
45
  raise NotImplementedError, "Subclasses must implement send_notification"
43
46
  end
47
+
48
+ # Send a JSON-RPC request to the client and wait for a response.
49
+ def send_request(method, params = nil)
50
+ raise NotImplementedError, "Subclasses must implement send_request"
51
+ end
52
+
53
+ private
54
+
55
+ def generate_request_id
56
+ SecureRandom.uuid
57
+ end
44
58
  end
45
59
  end
data/lib/mcp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MCP
4
- VERSION = "0.10.0"
4
+ VERSION = "0.15.0"
5
5
  end
data/lib/mcp.rb CHANGED
@@ -8,6 +8,8 @@ require_relative "mcp/version"
8
8
 
9
9
  module MCP
10
10
  autoload :Annotations, "mcp/annotations"
11
+ autoload :Cancellation, "mcp/cancellation"
12
+ autoload :CancelledError, "mcp/cancelled_error"
11
13
  autoload :Client, "mcp/client"
12
14
  autoload :Content, "mcp/content"
13
15
  autoload :Icon, "mcp/icon"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Model Context Protocol
@@ -28,15 +28,20 @@ email:
28
28
  - mcp-support@anthropic.com
29
29
  executables: []
30
30
  extensions: []
31
- extra_rdoc_files: []
31
+ extra_rdoc_files:
32
+ - LICENSE
33
+ - README.md
32
34
  files:
33
35
  - LICENSE
34
36
  - README.md
35
37
  - lib/json_rpc_handler.rb
36
38
  - lib/mcp.rb
37
39
  - lib/mcp/annotations.rb
40
+ - lib/mcp/cancellation.rb
41
+ - lib/mcp/cancelled_error.rb
38
42
  - lib/mcp/client.rb
39
43
  - lib/mcp/client/http.rb
44
+ - lib/mcp/client/paginated_result.rb
40
45
  - lib/mcp/client/stdio.rb
41
46
  - lib/mcp/client/tool.rb
42
47
  - lib/mcp/configuration.rb
@@ -56,6 +61,7 @@ files:
56
61
  - lib/mcp/resource_template.rb
57
62
  - lib/mcp/server.rb
58
63
  - lib/mcp/server/capabilities.rb
64
+ - lib/mcp/server/pagination.rb
59
65
  - lib/mcp/server/transports.rb
60
66
  - lib/mcp/server/transports/stdio_transport.rb
61
67
  - lib/mcp/server/transports/streamable_http_transport.rb
@@ -69,15 +75,14 @@ files:
69
75
  - lib/mcp/tool/response.rb
70
76
  - lib/mcp/tool/schema.rb
71
77
  - lib/mcp/transport.rb
72
- - lib/mcp/transports/stdio.rb
73
78
  - lib/mcp/version.rb
74
- homepage: https://github.com/modelcontextprotocol/ruby-sdk
79
+ homepage: https://ruby.sdk.modelcontextprotocol.io
75
80
  licenses:
76
81
  - Apache-2.0
77
82
  metadata:
78
83
  allowed_push_host: https://rubygems.org
79
- changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.10.0
80
- homepage_uri: https://github.com/modelcontextprotocol/ruby-sdk
84
+ changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.15.0
85
+ homepage_uri: https://ruby.sdk.modelcontextprotocol.io
81
86
  source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
82
87
  bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues
83
88
  documentation_uri: https://rubydoc.info/gems/mcp
@@ -1,15 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../server/transports/stdio_transport"
4
-
5
- warn <<~MESSAGE, uplevel: 3
6
- Use `require "mcp/server/transports/stdio_transport"` instead of `require "mcp/transports/stdio"`.
7
- Also use `MCP::Server::Transports::StdioTransport` instead of `MCP::Transports::StdioTransport`.
8
- This API is deprecated and will be removed in a future release.
9
- MESSAGE
10
-
11
- module MCP
12
- module Transports
13
- StdioTransport = Server::Transports::StdioTransport
14
- end
15
- end