mcp 0.14.0 → 0.16.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.
@@ -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
@@ -15,6 +16,48 @@ module MCP
15
16
  @client = nil
16
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)
@@ -78,6 +121,23 @@ module MCP
78
121
  send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id)
79
122
  end
80
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
+
81
141
  # Sends an elicitation complete notification scoped to this session.
82
142
  def notify_elicitation_complete(elicitation_id:)
83
143
  send_to_transport(Methods::NOTIFICATIONS_ELICITATION_COMPLETE, { elicitationId: elicitation_id })
@@ -121,32 +181,57 @@ module MCP
121
181
 
122
182
  private
123
183
 
124
- # Branches on `@session_id` because `StdioTransport` creates a `ServerSession` without
125
- # a `session_id` (`session_id: nil`), while `StreamableHTTPTransport` always provides one.
126
- #
127
- # TODO: When Ruby 2.7 support is dropped, replace with a direct call:
128
- # `@transport.send_notification(method, params, session_id: @session_id)` and
129
- # add `**` to `Transport#send_notification` and `StdioTransport#send_notification`.
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.
130
188
  def send_to_transport(method, params, related_request_id: nil)
131
- if @session_id
132
- @transport.send_notification(method, params, session_id: @session_id, related_request_id: related_request_id)
133
- else
134
- @transport.send_notification(method, params)
135
- end
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)
136
195
  end
137
196
 
138
- # Branches on `@session_id` because `StdioTransport` creates a `ServerSession` without
139
- # a `session_id` (`session_id: nil`), while `StreamableHTTPTransport` always provides one.
140
- #
141
- # TODO: When Ruby 2.7 support is dropped, replace with a direct call:
142
- # `@transport.send_request(method, params, session_id: @session_id)` and
143
- # add `**` to `Transport#send_request` and `StdioTransport#send_request`.
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.
144
203
  def send_to_transport_request(method, params, related_request_id: nil)
145
- if @session_id
146
- @transport.send_request(method, params, session_id: @session_id, related_request_id: related_request_id)
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
147
225
  else
148
- @transport.send_request(method, params)
226
+ allowed = parameters.filter_map { |type, name| name if type == :key || type == :keyreq }
227
+ kwargs.slice(*allowed)
149
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)
150
235
  end
151
236
  end
152
237
  end
@@ -5,6 +5,12 @@ require "json-schema"
5
5
  module MCP
6
6
  class Tool
7
7
  class Schema
8
+ # JSON Schema 2020-12 is the default dialect for MCP schema definitions
9
+ # per MCP 2025-11-25 (SEP-1613). Note: emission only — runtime validation
10
+ # is still performed against the JSON Schema draft-04 metaschema because
11
+ # the `json-schema` gem does not yet support 2020-12.
12
+ JSON_SCHEMA_2020_12_URI = "https://json-schema.org/draft/2020-12/schema"
13
+
8
14
  attr_reader :schema
9
15
 
10
16
  def initialize(schema = {})
@@ -18,17 +24,18 @@ module MCP
18
24
  end
19
25
 
20
26
  def to_h
21
- @schema
27
+ return @schema if @schema.key?(:"$schema")
28
+
29
+ { "$schema": JSON_SCHEMA_2020_12_URI }.merge(@schema)
22
30
  end
23
31
 
24
32
  private
25
33
 
26
34
  def fully_validate(data)
27
- JSON::Validator.fully_validate(to_h, data)
35
+ JSON::Validator.fully_validate(schema_for_validation, data)
28
36
  end
29
37
 
30
38
  def validate_schema!
31
- schema = to_h
32
39
  gem_path = File.realpath(Gem.loaded_specs["json-schema"].full_gem_path)
33
40
  schema_reader = JSON::Schema::Reader.new(
34
41
  accept_uri: false,
@@ -38,11 +45,22 @@ module MCP
38
45
  # Converts metaschema to a file URI for cross-platform compatibility
39
46
  metaschema_uri = JSON::Util::URI.file_uri(metaschema_path.expand_path.cleanpath.to_s.tr("\\", "/"))
40
47
  metaschema = metaschema_uri.to_s
41
- errors = JSON::Validator.fully_validate(metaschema, schema, schema_reader: schema_reader)
48
+ errors = JSON::Validator.fully_validate(metaschema, schema_for_validation, schema_reader: schema_reader)
42
49
  if errors.any?
43
50
  raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
44
51
  end
45
52
  end
53
+
54
+ # The `json-schema` gem's draft-04 validator cannot resolve newer or unknown `$schema`
55
+ # dialect URIs. Strip the top-level `$schema` before validation so a dialect URI
56
+ # (whether SDK-injected by `to_h` or user-supplied) does not break the validator.
57
+ def schema_for_validation
58
+ return @schema unless @schema.key?(:"$schema")
59
+
60
+ copy = @schema.dup
61
+ copy.delete(:"$schema")
62
+ copy
63
+ end
46
64
  end
47
65
  end
48
66
  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.14.0"
4
+ VERSION = "0.16.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.14.0
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Model Context Protocol
@@ -37,6 +37,8 @@ files:
37
37
  - lib/json_rpc_handler.rb
38
38
  - lib/mcp.rb
39
39
  - lib/mcp/annotations.rb
40
+ - lib/mcp/cancellation.rb
41
+ - lib/mcp/cancelled_error.rb
40
42
  - lib/mcp/client.rb
41
43
  - lib/mcp/client/http.rb
42
44
  - lib/mcp/client/paginated_result.rb
@@ -73,14 +75,13 @@ files:
73
75
  - lib/mcp/tool/response.rb
74
76
  - lib/mcp/tool/schema.rb
75
77
  - lib/mcp/transport.rb
76
- - lib/mcp/transports/stdio.rb
77
78
  - lib/mcp/version.rb
78
79
  homepage: https://ruby.sdk.modelcontextprotocol.io
79
80
  licenses:
80
81
  - Apache-2.0
81
82
  metadata:
82
83
  allowed_push_host: https://rubygems.org
83
- changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.14.0
84
+ changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.16.0
84
85
  homepage_uri: https://ruby.sdk.modelcontextprotocol.io
85
86
  source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
86
87
  bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues
@@ -99,7 +100,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
99
100
  - !ruby/object:Gem::Version
100
101
  version: '0'
101
102
  requirements: []
102
- rubygems_version: 4.0.6
103
+ rubygems_version: 4.0.10
103
104
  specification_version: 4
104
105
  summary: The official Ruby SDK for Model Context Protocol servers and clients
105
106
  test_files: []
@@ -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