mcp 0.14.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd749db825bb7586ca38768025dbd6c4e6e9e9aa5805666a7d9ee4b5f54a59f3
4
- data.tar.gz: a7dbc93581cfda1a22a6740f33047586902efcc30153a1ebda836a3623d267b0
3
+ metadata.gz: 0d7b34843d1b915df5d3df424b025c93bba1d50a5e19486f2dcd4a646227a749
4
+ data.tar.gz: 4327576e74518a21d1dbe57f7f22a6f17c8e486b7a9fb9c281c11d206dcd007e
5
5
  SHA512:
6
- metadata.gz: f9c6a4cc9f66449020c13220b5bb3d8c17cf2de3f6f40ea685eb3e56a47fc706c0d43514985914259c3e663877e9ef5b9396ed08200b8f73de0dea35f608e1a5
7
- data.tar.gz: 8a2976e9a5870aa4ce6404fd8123e67522dc2eb86804676c1feb574238315a69f2fb691acd3aabe22197ec77afe7c129c8225a6c318f8932e8fcb70ef3c7c595
6
+ metadata.gz: afe69100125bcc47dbfca963983eca401f5bf29b31c01748dd1a5e99537542a4cb2b021573d3635c94bb410cdd430c0e3ff023796d91299da366cc32c7999fba
7
+ data.tar.gz: 815153266ba50a7e3045d7058af383d2ba852e1eefbaa62bc78299da89621bb1c0e22c5d2738f56470f50b4e8bf85f0d83135b1789f2faa9c841cff5e29fa766
data/README.md CHANGED
@@ -41,6 +41,7 @@ It implements the Model Context Protocol specification, handling model context r
41
41
  - Supports roots (server-to-client filesystem boundary queries)
42
42
  - Supports sampling (server-to-client LLM completion requests)
43
43
  - Supports cursor-based pagination for list operations
44
+ - Supports server-side cancellation of in-flight requests (notifications/cancelled)
44
45
 
45
46
  ### Supported Methods
46
47
 
@@ -1096,9 +1097,137 @@ Notifications follow the JSON-RPC 2.0 specification and use these method names:
1096
1097
  - `notifications/tools/list_changed`
1097
1098
  - `notifications/prompts/list_changed`
1098
1099
  - `notifications/resources/list_changed`
1100
+ - `notifications/cancelled`
1099
1101
  - `notifications/progress`
1100
1102
  - `notifications/message`
1101
1103
 
1104
+ ### Cancellation
1105
+
1106
+ The MCP Ruby SDK supports server-side handling of the
1107
+ [MCP `notifications/cancelled` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation).
1108
+ When a client sends `notifications/cancelled` for an in-flight request, the server stops
1109
+ processing cooperatively and suppresses the JSON-RPC response for that request.
1110
+
1111
+ Cancellation is cooperative: the SDK does not forcibly terminate tool code. Instead,
1112
+ a `MCP::Cancellation` token is threaded through `server_context`, and long-running tools
1113
+ poll it to exit early. When a tool returns after cancellation has been observed,
1114
+ the server suppresses the JSON-RPC response, matching the spec. The `initialize` request
1115
+ is never cancellable per the spec.
1116
+
1117
+ > [!NOTE]
1118
+ > Client-initiated cancellation (`Client#cancel` equivalent that would also abort
1119
+ > the calling thread's wait) is not yet implemented. Sending `notifications/cancelled`
1120
+ > from the client side can be done by constructing the notification payload and writing it
1121
+ > directly through the transport, but the calling thread does not yet unwind automatically.
1122
+ > This is tracked as a follow-up.
1123
+
1124
+ #### Server-Side: Handlers that Check for Cancellation
1125
+
1126
+ Any handler that opts in to `server_context:` - tools (`Tool.call`), prompt templates,
1127
+ `resources_read_handler`, `completion_handler`, `resources_subscribe_handler`,
1128
+ `resources_unsubscribe_handler`, and `define_custom_method` blocks - receives
1129
+ an `MCP::ServerContext` wired to the in-flight request's cancellation token.
1130
+ Handlers check `cancelled?` in their work loop, or call `raise_if_cancelled!` to raise
1131
+ `MCP::CancelledError` at a safe point:
1132
+
1133
+ ```ruby
1134
+ class LongRunningTool < MCP::Tool
1135
+ description "A tool that supports cancellation"
1136
+ input_schema(properties: { count: { type: "integer" } }, required: ["count"])
1137
+
1138
+ def self.call(count:, server_context:)
1139
+ count.times do |i|
1140
+ # Exit early if the client has sent `notifications/cancelled`.
1141
+ break if server_context.cancelled?
1142
+
1143
+ do_work(i)
1144
+ end
1145
+
1146
+ MCP::Tool::Response.new([{ type: "text", text: "Done" }])
1147
+ end
1148
+ end
1149
+ ```
1150
+
1151
+ Alternatively, raise at the next safe point with `raise_if_cancelled!`:
1152
+
1153
+ ```ruby
1154
+ def self.call(count:, server_context:)
1155
+ count.times do |i|
1156
+ server_context.raise_if_cancelled!
1157
+
1158
+ do_work(i)
1159
+ end
1160
+
1161
+ MCP::Tool::Response.new([{ type: "text", text: "Done" }])
1162
+ end
1163
+ ```
1164
+
1165
+ When a handler observes cancellation (either by returning early with `cancelled?` or
1166
+ by raising `MCP::CancelledError` via `raise_if_cancelled!`), the server drops the response and
1167
+ no JSON-RPC result is sent to the client.
1168
+
1169
+ The same pattern works for other handler types:
1170
+
1171
+ ```ruby
1172
+ # resources/read
1173
+ server.resources_read_handler do |params, server_context:|
1174
+ server_context.raise_if_cancelled!
1175
+ # read the resource
1176
+ end
1177
+
1178
+ # completion/complete
1179
+ server.completion_handler do |params, server_context:|
1180
+ server_context.raise_if_cancelled!
1181
+ # compute completions
1182
+ end
1183
+
1184
+ # custom method
1185
+ server.define_custom_method(method_name: "custom/slow") do |params, server_context:|
1186
+ server_context.raise_if_cancelled!
1187
+ # do work
1188
+ end
1189
+
1190
+ # prompts (via Prompt subclass)
1191
+ class SlowPrompt < MCP::Prompt
1192
+ prompt_name "slow_prompt"
1193
+
1194
+ def self.template(args, server_context:)
1195
+ server_context.raise_if_cancelled!
1196
+ MCP::Prompt::Result.new(messages: [])
1197
+ end
1198
+ end
1199
+ ```
1200
+
1201
+ Handlers that do not declare a `server_context:` keyword continue to work unchanged -
1202
+ the opt-in detection only wraps the context when the block signature asks for it.
1203
+
1204
+ #### Nested Server-to-Client Requests Are Cancelled Automatically
1205
+
1206
+ When a tool handler is waiting on a nested server-to-client request
1207
+ (`server_context.create_sampling_message`, `create_form_elicitation`, or
1208
+ `create_url_elicitation`), cancelling the parent tool call automatically raises
1209
+ `MCP::CancelledError` from the nested call, so the tool does not need to wrap it
1210
+ in its own `cancelled?` checks:
1211
+
1212
+ ```ruby
1213
+ def self.call(server_context:)
1214
+ result = server_context.create_sampling_message(messages: messages, max_tokens: 100)
1215
+ # If the parent tools/call is cancelled while waiting above, MCP::CancelledError
1216
+ # is raised here and the tool can let it propagate or clean up as needed.
1217
+ MCP::Tool::Response.new([{ type: "text", text: result[:content][:text] }])
1218
+ rescue MCP::CancelledError
1219
+ # Optional: run cleanup. Re-raising (or letting it propagate) is fine; the server
1220
+ # will still suppress the JSON-RPC response per the MCP spec.
1221
+ raise
1222
+ end
1223
+ ```
1224
+
1225
+ Nested cancellation propagation is supported on `StreamableHTTPTransport` only.
1226
+ `StdioTransport` is single-threaded and blocks on `$stdin.gets`, so a nested
1227
+ `server_context.create_sampling_message` inside a tool runs to completion even if
1228
+ the parent `tools/call` is cancelled. The parent tool itself still observes cancellation
1229
+ via `server_context.cancelled?` between nested calls.
1230
+
1102
1231
  ### Ping
1103
1232
 
1104
1233
  The MCP Ruby SDK supports the
@@ -1597,7 +1726,7 @@ This class supports:
1597
1726
  - Tool invocation via the `tools/call` method (`MCP::Client#call_tools`)
1598
1727
  - Resource listing via the `resources/list` method (`MCP::Client#resources`)
1599
1728
  - Resource template listing via the `resources/templates/list` method (`MCP::Client#resource_templates`)
1600
- - Resource reading via the `resources/read` method (`MCP::Client#read_resources`)
1729
+ - Resource reading via the `resources/read` method (`MCP::Client#read_resource`)
1601
1730
  - Prompt listing via the `prompts/list` method (`MCP::Client#prompts`)
1602
1731
  - Prompt retrieval via the `prompts/get` method (`MCP::Client#get_prompt`)
1603
1732
  - Completion requests via the `completion/complete` method (`MCP::Client#complete`)
@@ -1653,6 +1782,9 @@ stdio_transport = MCP::Client::Stdio.new(
1653
1782
  )
1654
1783
  client = MCP::Client.new(transport: stdio_transport)
1655
1784
 
1785
+ # Perform the MCP initialization handshake before sending any requests.
1786
+ client.connect
1787
+
1656
1788
  # List available tools.
1657
1789
  tools = client.tools
1658
1790
  tools.each do |tool|
@@ -1693,6 +1825,9 @@ Example usage:
1693
1825
  http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp")
1694
1826
  client = MCP::Client.new(transport: http_transport)
1695
1827
 
1828
+ # Perform the MCP initialization handshake before sending any requests.
1829
+ client.connect
1830
+
1696
1831
  # List available tools
1697
1832
  tools = client.tools
1698
1833
  tools.each do |tool|
@@ -18,6 +18,11 @@ module JsonRpcHandler
18
18
 
19
19
  DEFAULT_ALLOWED_ID_CHARACTERS = /\A[a-zA-Z0-9_-]+\z/
20
20
 
21
+ # Sentinel return value from a handler. When a handler returns this,
22
+ # `process_request` emits no JSON-RPC response for the request,
23
+ # matching the notification-style semantics (id is ignored).
24
+ NO_RESPONSE = Object.new.freeze
25
+
21
26
  extend self
22
27
 
23
28
  def handle(request, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder)
@@ -103,6 +108,7 @@ module JsonRpcHandler
103
108
  end
104
109
 
105
110
  result = method.call(params)
111
+ return if result.equal?(NO_RESPONSE)
106
112
 
107
113
  success_response(id: id, result: result)
108
114
  rescue MCP::Server::RequestHandlerError => e
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cancelled_error"
4
+
5
+ module MCP
6
+ class Cancellation
7
+ attr_reader :reason, :request_id
8
+
9
+ def initialize(request_id: nil)
10
+ @request_id = request_id
11
+ @reason = nil
12
+ @cancelled = false
13
+ @callbacks = []
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ def cancelled?
18
+ @mutex.synchronize { @cancelled }
19
+ end
20
+
21
+ def cancel(reason: nil)
22
+ callbacks = @mutex.synchronize do
23
+ return false if @cancelled
24
+
25
+ @cancelled = true
26
+ @reason = reason
27
+ @callbacks.tap { @callbacks = [] }
28
+ end
29
+
30
+ callbacks.each do |callback|
31
+ callback.call(reason)
32
+ rescue StandardError => e
33
+ MCP.configuration.exception_reporter.call(e, { error: "Cancellation callback failed" })
34
+ end
35
+
36
+ true
37
+ end
38
+
39
+ # Registers a callback invoked synchronously on the first `cancel` call.
40
+ # If already cancelled, fires immediately.
41
+ #
42
+ # Returns the block itself as a handle that can be passed to `off_cancel`
43
+ # to deregister it (e.g. when a nested request completes normally and the
44
+ # hook should not fire on a later parent cancellation).
45
+ def on_cancel(&block)
46
+ fire_now = false
47
+ @mutex.synchronize do
48
+ if @cancelled
49
+ fire_now = true
50
+ else
51
+ @callbacks << block
52
+ end
53
+ end
54
+
55
+ block.call(@reason) if fire_now
56
+ block
57
+ end
58
+
59
+ # Removes a previously-registered `on_cancel` callback. Returns `true`
60
+ # if the callback was still pending (i.e. had not yet fired), `false`
61
+ # otherwise. Safe to call with `nil`.
62
+ def off_cancel(handle)
63
+ return false unless handle
64
+
65
+ @mutex.synchronize { !@callbacks.delete(handle).nil? }
66
+ end
67
+
68
+ def raise_if_cancelled!
69
+ raise CancelledError.new(request_id: @request_id, reason: @reason) if cancelled?
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class CancelledError < StandardError
5
+ attr_reader :request_id, :reason
6
+
7
+ def initialize(message = "Request was cancelled", request_id: nil, reason: nil)
8
+ super(message)
9
+ @request_id = request_id
10
+ @reason = reason
11
+ end
12
+ end
13
+ end
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+ require_relative "../../json_rpc_handler"
5
+ require_relative "../configuration"
3
6
  require_relative "../methods"
7
+ require_relative "../version"
4
8
 
5
9
  module MCP
6
10
  class Client
@@ -13,7 +17,7 @@ module MCP
13
17
  SESSION_ID_HEADER = "Mcp-Session-Id"
14
18
  PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
15
19
 
16
- attr_reader :url, :session_id, :protocol_version
20
+ attr_reader :url, :session_id, :protocol_version, :server_info
17
21
 
18
22
  def initialize(url:, headers: {}, &block)
19
23
  @url = url
@@ -21,6 +25,94 @@ module MCP
21
25
  @faraday_customizer = block
22
26
  @session_id = nil
23
27
  @protocol_version = nil
28
+ @server_info = nil
29
+ @connected = false
30
+ end
31
+
32
+ # Performs the MCP `initialize` handshake: sends an `initialize` request
33
+ # followed by the required `notifications/initialized` notification. The
34
+ # server's `InitializeResult` (protocol version, capabilities, server
35
+ # info, instructions) is cached on the transport and returned.
36
+ #
37
+ # Idempotent: a second call returns the cached `InitializeResult` without
38
+ # contacting the server. After `close`, state is cleared and `connect`
39
+ # will handshake again.
40
+ #
41
+ # @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
42
+ # Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
43
+ # @param protocol_version [String, nil] Protocol version to offer. Defaults
44
+ # to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`.
45
+ # @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`.
46
+ # @return [Hash] The server's `InitializeResult`.
47
+ # @raise [RequestHandlerError] If the server responds with a JSON-RPC error
48
+ # or a malformed result.
49
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
50
+ def connect(client_info: nil, protocol_version: nil, capabilities: {})
51
+ return @server_info if connected?
52
+
53
+ client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
54
+ protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
55
+
56
+ response = send_request(request: {
57
+ jsonrpc: JsonRpcHandler::Version::V2_0,
58
+ id: SecureRandom.uuid,
59
+ method: MCP::Methods::INITIALIZE,
60
+ params: {
61
+ protocolVersion: protocol_version,
62
+ capabilities: capabilities,
63
+ clientInfo: client_info,
64
+ },
65
+ })
66
+
67
+ if response.is_a?(Hash) && response.key?("error")
68
+ clear_session
69
+ error = response["error"]
70
+ raise RequestHandlerError.new(
71
+ "Server initialization failed: #{error["message"]}",
72
+ { method: MCP::Methods::INITIALIZE },
73
+ error_type: :internal_error,
74
+ )
75
+ end
76
+
77
+ unless response.is_a?(Hash) && response["result"].is_a?(Hash)
78
+ clear_session
79
+ raise RequestHandlerError.new(
80
+ "Server initialization failed: missing result in response",
81
+ { method: MCP::Methods::INITIALIZE },
82
+ error_type: :internal_error,
83
+ )
84
+ end
85
+
86
+ @server_info = response["result"]
87
+ negotiated_protocol_version = @server_info["protocolVersion"]
88
+ unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
89
+ clear_session
90
+ raise RequestHandlerError.new(
91
+ "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
92
+ { method: MCP::Methods::INITIALIZE },
93
+ error_type: :internal_error,
94
+ )
95
+ end
96
+
97
+ begin
98
+ send_request(request: {
99
+ jsonrpc: JsonRpcHandler::Version::V2_0,
100
+ method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
101
+ })
102
+ rescue StandardError
103
+ clear_session
104
+ raise
105
+ end
106
+
107
+ @connected = true
108
+ @server_info
109
+ end
110
+
111
+ # Returns true once `connect` has completed the full handshake
112
+ # (`initialize` response received and `notifications/initialized` sent).
113
+ # Returns false before the first handshake and after `close`.
114
+ def connected?
115
+ @connected
24
116
  end
25
117
 
26
118
  # Sends a JSON-RPC request and returns the parsed response body.
@@ -105,7 +197,10 @@ module MCP
105
197
  # session state is cleared either way.
106
198
  # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
107
199
  def close
108
- return unless @session_id
200
+ unless @session_id
201
+ clear_session
202
+ return
203
+ end
109
204
 
110
205
  begin
111
206
  client.delete("", nil, session_headers)
@@ -159,6 +254,8 @@ module MCP
159
254
  def clear_session
160
255
  @session_id = nil
161
256
  @protocol_version = nil
257
+ @server_info = nil
258
+ @connected = false
162
259
  end
163
260
 
164
261
  def require_faraday!
@@ -19,7 +19,7 @@ module MCP
19
19
  CLOSE_TIMEOUT = 2
20
20
  STDERR_READ_SIZE = 4096
21
21
 
22
- attr_reader :command, :args, :env
22
+ attr_reader :command, :args, :env, :server_info
23
23
 
24
24
  def initialize(command:, args: [], env: nil, read_timeout: nil)
25
25
  @command = command
@@ -33,11 +33,108 @@ module MCP
33
33
  @stderr_thread = nil
34
34
  @started = false
35
35
  @initialized = false
36
+ @server_info = nil
37
+ end
38
+
39
+ # Performs the MCP `initialize` handshake: sends an `initialize` request
40
+ # followed by the required `notifications/initialized` notification. The
41
+ # server's `InitializeResult` (protocol version, capabilities, server
42
+ # info, instructions) is cached on the transport and returned.
43
+ #
44
+ # Idempotent: a second call returns the cached `InitializeResult` without
45
+ # contacting the server. After `close`, state is cleared and `connect`
46
+ # will handshake again. Spawns the subprocess via `start` if it has not
47
+ # been started yet.
48
+ #
49
+ # @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
50
+ # Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
51
+ # @param protocol_version [String, nil] Protocol version to offer. Defaults
52
+ # to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`.
53
+ # @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`.
54
+ # @return [Hash] The server's `InitializeResult`.
55
+ # @raise [RequestHandlerError] If the server responds with a JSON-RPC error,
56
+ # a malformed result, or an unsupported protocol version.
57
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
58
+ def connect(client_info: nil, protocol_version: nil, capabilities: {})
59
+ return @server_info if @initialized
60
+
61
+ start unless @started
62
+
63
+ client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
64
+ protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
65
+
66
+ init_request = {
67
+ jsonrpc: JsonRpcHandler::Version::V2_0,
68
+ id: SecureRandom.uuid,
69
+ method: MCP::Methods::INITIALIZE,
70
+ params: {
71
+ protocolVersion: protocol_version,
72
+ capabilities: capabilities,
73
+ clientInfo: client_info,
74
+ },
75
+ }
76
+
77
+ write_message(init_request)
78
+ response = read_response(init_request)
79
+
80
+ if response.key?("error")
81
+ error = response["error"]
82
+ raise RequestHandlerError.new(
83
+ "Server initialization failed: #{error["message"]}",
84
+ { method: MCP::Methods::INITIALIZE },
85
+ error_type: :internal_error,
86
+ )
87
+ end
88
+
89
+ unless response["result"].is_a?(Hash)
90
+ raise RequestHandlerError.new(
91
+ "Server initialization failed: missing result in response",
92
+ { method: MCP::Methods::INITIALIZE },
93
+ error_type: :internal_error,
94
+ )
95
+ end
96
+
97
+ @server_info = response["result"]
98
+
99
+ negotiated_protocol_version = @server_info["protocolVersion"]
100
+ unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
101
+ # Per spec, if the client does not support the server's returned protocol version,
102
+ # the client SHOULD disconnect. Roll back the cached `InitializeResult` before
103
+ # raising so a retry starts without a stale `server_info`.
104
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
105
+ @server_info = nil
106
+ raise RequestHandlerError.new(
107
+ "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
108
+ { method: MCP::Methods::INITIALIZE },
109
+ error_type: :internal_error,
110
+ )
111
+ end
112
+
113
+ begin
114
+ notification = {
115
+ jsonrpc: JsonRpcHandler::Version::V2_0,
116
+ method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
117
+ }
118
+ write_message(notification)
119
+ rescue StandardError
120
+ @server_info = nil
121
+ raise
122
+ end
123
+
124
+ @initialized = true
125
+ @server_info
126
+ end
127
+
128
+ # Returns true once `connect` (or the implicit handshake on the first
129
+ # `send_request`) has completed. Returns false before the handshake
130
+ # and after `close`.
131
+ def connected?
132
+ @initialized
36
133
  end
37
134
 
38
135
  def send_request(request:)
39
136
  start unless @started
40
- initialize_session unless @initialized
137
+ connect unless @initialized
41
138
 
42
139
  write_message(request)
43
140
  read_response(request)
@@ -98,57 +195,11 @@ module MCP
98
195
  @stderr_thread.join(CLOSE_TIMEOUT)
99
196
  @started = false
100
197
  @initialized = false
198
+ @server_info = nil
101
199
  end
102
200
 
103
201
  private
104
202
 
105
- # The client MUST send a protocol version it supports. This SHOULD be the latest version.
106
- # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
107
- #
108
- # Always sends `LATEST_STABLE_PROTOCOL_VERSION`, matching the Python and TypeScript SDKs:
109
- # https://github.com/modelcontextprotocol/python-sdk/blob/v1.26.0/src/mcp/client/session.py#L175
110
- # https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.27.1/src/client/index.ts#L495
111
- def initialize_session
112
- init_request = {
113
- jsonrpc: JsonRpcHandler::Version::V2_0,
114
- id: SecureRandom.uuid,
115
- method: MCP::Methods::INITIALIZE,
116
- params: {
117
- protocolVersion: MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION,
118
- capabilities: {},
119
- clientInfo: { name: "mcp-ruby-client", version: MCP::VERSION },
120
- },
121
- }
122
-
123
- write_message(init_request)
124
- response = read_response(init_request)
125
-
126
- if response.key?("error")
127
- error = response["error"]
128
- raise RequestHandlerError.new(
129
- "Server initialization failed: #{error["message"]}",
130
- { method: MCP::Methods::INITIALIZE },
131
- error_type: :internal_error,
132
- )
133
- end
134
-
135
- unless response.key?("result")
136
- raise RequestHandlerError.new(
137
- "Server initialization failed: missing result in response",
138
- { method: MCP::Methods::INITIALIZE },
139
- error_type: :internal_error,
140
- )
141
- end
142
-
143
- notification = {
144
- jsonrpc: JsonRpcHandler::Version::V2_0,
145
- method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
146
- }
147
- write_message(notification)
148
-
149
- @initialized = true
150
- end
151
-
152
203
  def write_message(message)
153
204
  ensure_running!
154
205
  json = JSON.generate(message)
data/lib/mcp/client.rb CHANGED
@@ -59,6 +59,46 @@ module MCP
59
59
  # So keeping it public
60
60
  attr_reader :transport
61
61
 
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
+
62
102
  # Returns a single page of tools from the server.
63
103
  #
64
104
  # @param cursor [String, nil] Cursor from a previous page response.
@@ -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 }
@@ -175,7 +175,7 @@ module MCP
175
175
  # sends the request via SSE stream, then blocks on `queue.pop`.
176
176
  # When the client POSTs a response, `handle_response` matches it by `request_id`
177
177
  # and pushes the result onto the queue, unblocking this thread.
178
- 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)
179
179
  if @stateless
180
180
  raise "Stateless mode does not support server-to-client requests."
181
181
  end
@@ -190,6 +190,7 @@ module MCP
190
190
 
191
191
  request_id = generate_request_id
192
192
  queue = Queue.new
193
+ cancel_hook = nil
193
194
 
194
195
  request = { jsonrpc: "2.0", id: request_id, method: method }
195
196
  request[:params] = params if params
@@ -229,6 +230,16 @@ module MCP
229
230
  raise "No active stream for #{method} request."
230
231
  end
231
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
+
232
243
  response = queue.pop
233
244
 
234
245
  if response.is_a?(Hash) && response.key?(:error)
@@ -239,8 +250,18 @@ module MCP
239
250
  raise "SSE session closed while waiting for #{method} response."
240
251
  end
241
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
+
242
262
  response
243
263
  ensure
264
+ parent_cancellation.off_cancel(cancel_hook) if cancel_hook
244
265
  if request_id
245
266
  @mutex.synchronize do
246
267
  @pending_responses.delete(request_id)
@@ -248,6 +269,24 @@ module MCP
248
269
  end
249
270
  end
250
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
+
251
290
  private
252
291
 
253
292
  def start_reaper_thread
@@ -309,6 +348,7 @@ module MCP
309
348
  return missing_session_id_response if !@stateless && !session_id
310
349
 
311
350
  if notification?(body)
351
+ dispatch_notification(body_string, session_id)
312
352
  handle_accepted
313
353
  elsif response?(body)
314
354
  return session_not_found_response if !@stateless && !session_exists?(session_id)
@@ -459,6 +499,22 @@ module MCP
459
499
  !body[:id] && !!body[:method]
460
500
  end
461
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
+
462
518
  def response?(body)
463
519
  !!body[:id] && !body[:method]
464
520
  end
@@ -536,6 +592,12 @@ module MCP
536
592
  handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
537
593
  else
538
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
+
539
601
  [200, { "Content-Type" => "application/json" }, [response]]
540
602
  end
541
603
  end
data/lib/mcp/server.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../json_rpc_handler"
4
+ require_relative "cancellation"
5
+ require_relative "cancelled_error"
4
6
  require_relative "instrumentation"
5
7
  require_relative "methods"
6
8
  require_relative "logging_message_notification"
@@ -384,6 +386,13 @@ module MCP
384
386
  end
385
387
 
386
388
  def handle_request(request, method, session: nil, related_request_id: nil)
389
+ # `notifications/cancelled` is dispatched directly: it is a notification (no JSON-RPC id)
390
+ # and intentionally bypasses the `@handlers` lookup, capability check, in-flight registry,
391
+ # and rescue blocks below.
392
+ if method == Methods::NOTIFICATIONS_CANCELLED
393
+ return ->(params) { handle_cancelled_notification(params, session: session) }
394
+ end
395
+
387
396
  handler = @handlers[method]
388
397
  unless handler
389
398
  instrument_call("unsupported_method", server_context: { request: request }) do
@@ -395,6 +404,12 @@ module MCP
395
404
 
396
405
  Methods.ensure_capability!(method, capabilities)
397
406
 
407
+ # `initialize` MUST NOT be cancelled (MCP spec 2025-11-25, cancellation item 2),
408
+ # so do not track it in the in-flight registry.
409
+ cancellation = if related_request_id && method != Methods::INITIALIZE
410
+ session&.register_in_flight(related_request_id)
411
+ end
412
+
398
413
  ->(params) {
399
414
  reported_exception = nil
400
415
  instrument_call(
@@ -406,23 +421,33 @@ module MCP
406
421
  when Methods::INITIALIZE
407
422
  init(params, session: session)
408
423
  when Methods::RESOURCES_READ
409
- { contents: @handlers[Methods::RESOURCES_READ].call(params) }
424
+ { contents: read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation) }
410
425
  when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
411
- @handlers[method].call(params)
426
+ dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
412
427
  {}
413
428
  when Methods::TOOLS_CALL
414
- call_tool(params, session: session, related_request_id: related_request_id)
429
+ call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
430
+ when Methods::PROMPTS_GET
431
+ get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
415
432
  when Methods::COMPLETION_COMPLETE
416
- complete(params)
433
+ complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
417
434
  when Methods::LOGGING_SET_LEVEL
418
435
  configure_logging_level(params, session: session)
419
436
  else
420
- @handlers[method].call(params)
437
+ dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
421
438
  end
422
439
  client = session&.client || @client
423
440
  add_instrumentation_data(client: client) if client
424
441
 
442
+ if cancellation&.cancelled?
443
+ add_instrumentation_data(cancelled: true, cancellation_reason: cancellation.reason)
444
+ next JsonRpcHandler::NO_RESPONSE
445
+ end
446
+
425
447
  result
448
+ rescue CancelledError => e
449
+ add_instrumentation_data(cancelled: true, cancellation_reason: e.reason)
450
+ next JsonRpcHandler::NO_RESPONSE
426
451
  rescue RequestHandlerError => e
427
452
  report_exception(e.original_error || e, { request: request })
428
453
  add_instrumentation_data(error: e.error_type)
@@ -434,10 +459,23 @@ module MCP
434
459
  wrapped = RequestHandlerError.new("Internal error handling #{method} request", request, original_error: e)
435
460
  reported_exception = wrapped
436
461
  raise wrapped
462
+ ensure
463
+ session&.unregister_in_flight(related_request_id) if related_request_id
437
464
  end
438
465
  }
439
466
  end
440
467
 
468
+ def handle_cancelled_notification(params, session: nil)
469
+ return unless session
470
+ return unless params.is_a?(Hash)
471
+
472
+ request_id = params[:requestId] || params["requestId"]
473
+ return if request_id.nil?
474
+
475
+ reason = params[:reason] || params["reason"]
476
+ session.cancel_incoming(request_id: request_id, reason: reason)
477
+ end
478
+
441
479
  def default_capabilities
442
480
  {
443
481
  tools: { listChanged: true },
@@ -516,7 +554,7 @@ module MCP
516
554
  { tools: page[:items], nextCursor: page[:next_cursor] }.compact
517
555
  end
518
556
 
519
- def call_tool(request, session: nil, related_request_id: nil)
557
+ def call_tool(request, session: nil, related_request_id: nil, cancellation: nil)
520
558
  tool_name = request[:name]
521
559
 
522
560
  tool = tools[tool_name]
@@ -533,7 +571,7 @@ module MCP
533
571
  add_instrumentation_data(error: :missing_required_arguments)
534
572
 
535
573
  missing = tool.input_schema.missing_required_arguments(arguments).join(", ")
536
- raise RequestHandlerError.new("Missing required arguments: #{missing}", request, error_type: :invalid_params)
574
+ return error_tool_response("Missing required arguments: #{missing}")
537
575
  end
538
576
 
539
577
  if configuration.validate_tool_call_arguments && tool.input_schema
@@ -542,14 +580,18 @@ module MCP
542
580
  rescue Tool::InputSchema::ValidationError => e
543
581
  add_instrumentation_data(error: :invalid_schema)
544
582
 
545
- raise RequestHandlerError.new(e.message, request, error_type: :invalid_params)
583
+ return error_tool_response(e.message)
546
584
  end
547
585
  end
548
586
 
549
587
  progress_token = request.dig(:_meta, :progressToken)
550
588
 
551
- call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id)
552
- rescue RequestHandlerError
589
+ call_tool_with_args(
590
+ tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation
591
+ )
592
+ rescue RequestHandlerError, CancelledError
593
+ # CancelledError is intentionally not wrapped so `handle_request` can turn it into
594
+ # `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec.
553
595
  raise
554
596
  rescue => e
555
597
  raise RequestHandlerError.new(
@@ -566,7 +608,7 @@ module MCP
566
608
  { prompts: page[:items], nextCursor: page[:next_cursor] }.compact
567
609
  end
568
610
 
569
- def get_prompt(request)
611
+ def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil)
570
612
  prompt_name = request[:name]
571
613
  prompt = @prompts[prompt_name]
572
614
  unless prompt
@@ -579,7 +621,14 @@ module MCP
579
621
  prompt_args = request[:arguments]
580
622
  prompt.validate_arguments!(prompt_args)
581
623
 
582
- call_prompt_template_with_args(prompt, prompt_args, server_context_with_meta(request))
624
+ server_context = build_server_context(
625
+ request: request,
626
+ session: session,
627
+ related_request_id: related_request_id,
628
+ cancellation: cancellation,
629
+ )
630
+
631
+ call_prompt_template_with_args(prompt, prompt_args, server_context)
583
632
  end
584
633
 
585
634
  def list_resources(request)
@@ -600,14 +649,82 @@ module MCP
600
649
  { resourceTemplates: page[:items], nextCursor: page[:next_cursor] }.compact
601
650
  end
602
651
 
603
- def complete(params)
652
+ def complete(params, session: nil, related_request_id: nil, cancellation: nil)
604
653
  validate_completion_params!(params)
605
654
 
606
- result = @handlers[Methods::COMPLETION_COMPLETE].call(params)
655
+ result = dispatch_optional_context_handler(
656
+ @handlers[Methods::COMPLETION_COMPLETE],
657
+ params,
658
+ session: session,
659
+ related_request_id: related_request_id,
660
+ cancellation: cancellation,
661
+ )
607
662
 
608
663
  normalize_completion_result(result)
609
664
  end
610
665
 
666
+ # Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`,
667
+ # pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or
668
+ # `server_context.raise_if_cancelled!`.
669
+ def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil)
670
+ dispatch_optional_context_handler(
671
+ @handlers[Methods::RESOURCES_READ],
672
+ request,
673
+ session: session,
674
+ related_request_id: related_request_id,
675
+ cancellation: cancellation,
676
+ )
677
+ end
678
+
679
+ # Opt-in `server_context:` dispatch for block-based handlers registered via `resources_read_handler`,
680
+ # `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, or `define_custom_method`.
681
+ # Existing handlers that only accept `params` are called unchanged; handlers that declare a `server_context:`
682
+ # keyword receive an `MCP::ServerContext` wrapping the raw server context with cancellation plumbing.
683
+ def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil)
684
+ return handler.call(params) unless handler_declares_server_context?(handler)
685
+
686
+ server_context = build_server_context(
687
+ request: params,
688
+ session: session,
689
+ related_request_id: related_request_id,
690
+ cancellation: cancellation,
691
+ )
692
+ handler.call(params, server_context: server_context)
693
+ end
694
+
695
+ # Stricter than `accepts_server_context?`: requires `server_context` to appear as a named keyword parameter
696
+ # (`:key` optional, `:keyreq` required). Positional parameters named `server_context` (`:req` / `:opt`) are NOT
697
+ # treated as opt-in - otherwise `handler.call(params, server_context: ctx)` would pass the `{server_context: ctx}`
698
+ # Hash as the handler's second positional argument, which is never what the user meant.
699
+ #
700
+ # `**kwargs`-only signatures (`:keyrest` without a named `server_context`) are also not opt-in here,
701
+ # because the dispatch site passes a positional `params`, and a `**kwargs`-only block cannot accept
702
+ # that positional argument (lambdas/methods raise `ArgumentError`; non-lambda procs silently drop `params`).
703
+ # Tool handlers intentionally allow `**kwargs` opt-in via `accepts_server_context?` because they are invoked
704
+ # via `tool.call(**args, server_context: …)` without a positional argument.
705
+ def handler_declares_server_context?(handler)
706
+ return false unless handler.respond_to?(:parameters)
707
+
708
+ handler.parameters.any? do |type, name|
709
+ name == :server_context && (type == :key || type == :keyreq)
710
+ end
711
+ end
712
+
713
+ # Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers
714
+ # (progress, cancellation, nested server-to-client requests).
715
+ def build_server_context(request:, session:, related_request_id:, cancellation:)
716
+ meta_source = request.is_a?(Hash) ? request : {}
717
+ progress_token = meta_source.dig(:_meta, :progressToken)
718
+ progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
719
+ ServerContext.new(
720
+ server_context_with_meta(meta_source),
721
+ progress: progress,
722
+ notification_target: session,
723
+ related_request_id: related_request_id,
724
+ cancellation: cancellation,
725
+ )
726
+ end
727
+
611
728
  def report_exception(exception, server_context = {})
612
729
  configuration.exception_reporter.call(exception, server_context)
613
730
  end
@@ -628,18 +745,32 @@ module MCP
628
745
  ).to_h
629
746
  end
630
747
 
748
+ # Whether a tool/prompt handler opts in to receiving an `MCP::ServerContext`.
749
+ # Recognizes `:keyrest` (`**kwargs`) because tools are invoked without a positional argument
750
+ # (`tool.call(**args, server_context:)`), soa `**kwargs`-only signature safely captures `server_context:`.
751
+ # Named keyword `server_context` must be `:key` or `:keyreq` - positional parameters (`:req` / `:opt`) that
752
+ # happen to be named `server_context` are excluded because the call site passes `server_context:` as a keyword,
753
+ # and a positional slot would receive the `{server_context: ctx}` Hash instead.
631
754
  def accepts_server_context?(method_object)
632
755
  parameters = method_object.parameters
633
756
 
634
- parameters.any? { |type, name| type == :keyrest || name == :server_context }
757
+ parameters.any? do |type, name|
758
+ type == :keyrest || (name == :server_context && (type == :key || type == :keyreq))
759
+ end
635
760
  end
636
761
 
637
- def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil)
762
+ def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil)
638
763
  args = arguments&.transform_keys(&:to_sym) || {}
639
764
 
640
765
  if accepts_server_context?(tool.method(:call))
641
766
  progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
642
- server_context = ServerContext.new(context, progress: progress, notification_target: session, related_request_id: related_request_id)
767
+ server_context = ServerContext.new(
768
+ context,
769
+ progress: progress,
770
+ notification_target: session,
771
+ related_request_id: related_request_id,
772
+ cancellation: cancellation,
773
+ )
643
774
  tool.call(**args, server_context: server_context).to_h
644
775
  else
645
776
  tool.call(**args).to_h
@@ -2,11 +2,22 @@
2
2
 
3
3
  module MCP
4
4
  class ServerContext
5
- def initialize(context, progress:, notification_target:, related_request_id: nil)
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
9
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!
10
21
  end
11
22
 
12
23
  # Reports progress for the current tool operation.
@@ -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
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.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.14.0
4
+ version: 0.15.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.15.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
@@ -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