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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd749db825bb7586ca38768025dbd6c4e6e9e9aa5805666a7d9ee4b5f54a59f3
4
- data.tar.gz: a7dbc93581cfda1a22a6740f33047586902efcc30153a1ebda836a3623d267b0
3
+ metadata.gz: 5b7c0c225e89be4cfc9c73a0707b8a6a61d0d76a8a21340b20b3dd276f36eec6
4
+ data.tar.gz: fdd1b11cbe6ac733820bc604f72c95c6f0aa67162776c8778de7adb04aa7ca39
5
5
  SHA512:
6
- metadata.gz: f9c6a4cc9f66449020c13220b5bb3d8c17cf2de3f6f40ea685eb3e56a47fc706c0d43514985914259c3e663877e9ef5b9396ed08200b8f73de0dea35f608e1a5
7
- data.tar.gz: 8a2976e9a5870aa4ce6404fd8123e67522dc2eb86804676c1feb574238315a69f2fb691acd3aabe22197ec77afe7c129c8225a6c318f8932e8fcb70ef3c7c595
6
+ metadata.gz: c6dce7fe76f5f3996c9f5caffe884237147c74840d4d68dce5ec6ec24be49e65e0eea39306c2c52353c5014c8247295491b3337e166cec662eca19742565d741
7
+ data.tar.gz: b944ae1938952c5e4fd20fb4b5d1ade071cf5ffa193b6c0cd56ba25e79aa583e2de38804045ce0d038eb2e38b5ea5bed4fd6b6073a16fea8bdba02f7e1e8b148
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
 
@@ -644,6 +645,19 @@ MCP spec for the [Output Schema](https://modelcontextprotocol.io/specification/l
644
645
 
645
646
  The output schema follows standard JSON Schema format and helps ensure consistent data exchange between MCP servers and clients.
646
647
 
648
+ By default, server-side validation of tool results against `output_schema` is disabled for backwards compatibility. To validate successful tool responses, enable `validate_tool_call_results`:
649
+
650
+ ```ruby
651
+ configuration = MCP::Configuration.new(validate_tool_call_results: true)
652
+ server = MCP::Server.new(
653
+ name: "example_server",
654
+ tools: [WeatherTool],
655
+ configuration: configuration
656
+ )
657
+ ```
658
+
659
+ When enabled, successful tool responses for tools with an `output_schema` must include `structured_content` that conforms to the schema. Error responses are not validated against the output schema.
660
+
647
661
  ### Tool Responses with Structured Content
648
662
 
649
663
  Tools can return structured data alongside text content using the `structured_content` parameter.
@@ -1096,9 +1110,137 @@ Notifications follow the JSON-RPC 2.0 specification and use these method names:
1096
1110
  - `notifications/tools/list_changed`
1097
1111
  - `notifications/prompts/list_changed`
1098
1112
  - `notifications/resources/list_changed`
1113
+ - `notifications/cancelled`
1099
1114
  - `notifications/progress`
1100
1115
  - `notifications/message`
1101
1116
 
1117
+ ### Cancellation
1118
+
1119
+ The MCP Ruby SDK supports server-side handling of the
1120
+ [MCP `notifications/cancelled` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation).
1121
+ When a client sends `notifications/cancelled` for an in-flight request, the server stops
1122
+ processing cooperatively and suppresses the JSON-RPC response for that request.
1123
+
1124
+ Cancellation is cooperative: the SDK does not forcibly terminate tool code. Instead,
1125
+ a `MCP::Cancellation` token is threaded through `server_context`, and long-running tools
1126
+ poll it to exit early. When a tool returns after cancellation has been observed,
1127
+ the server suppresses the JSON-RPC response, matching the spec. The `initialize` request
1128
+ is never cancellable per the spec.
1129
+
1130
+ > [!NOTE]
1131
+ > Client-initiated cancellation (`Client#cancel` equivalent that would also abort
1132
+ > the calling thread's wait) is not yet implemented. Sending `notifications/cancelled`
1133
+ > from the client side can be done by constructing the notification payload and writing it
1134
+ > directly through the transport, but the calling thread does not yet unwind automatically.
1135
+ > This is tracked as a follow-up.
1136
+
1137
+ #### Server-Side: Handlers that Check for Cancellation
1138
+
1139
+ Any handler that opts in to `server_context:` - tools (`Tool.call`), prompt templates,
1140
+ `resources_read_handler`, `completion_handler`, `resources_subscribe_handler`,
1141
+ `resources_unsubscribe_handler`, and `define_custom_method` blocks - receives
1142
+ an `MCP::ServerContext` wired to the in-flight request's cancellation token.
1143
+ Handlers check `cancelled?` in their work loop, or call `raise_if_cancelled!` to raise
1144
+ `MCP::CancelledError` at a safe point:
1145
+
1146
+ ```ruby
1147
+ class LongRunningTool < MCP::Tool
1148
+ description "A tool that supports cancellation"
1149
+ input_schema(properties: { count: { type: "integer" } }, required: ["count"])
1150
+
1151
+ def self.call(count:, server_context:)
1152
+ count.times do |i|
1153
+ # Exit early if the client has sent `notifications/cancelled`.
1154
+ break if server_context.cancelled?
1155
+
1156
+ do_work(i)
1157
+ end
1158
+
1159
+ MCP::Tool::Response.new([{ type: "text", text: "Done" }])
1160
+ end
1161
+ end
1162
+ ```
1163
+
1164
+ Alternatively, raise at the next safe point with `raise_if_cancelled!`:
1165
+
1166
+ ```ruby
1167
+ def self.call(count:, server_context:)
1168
+ count.times do |i|
1169
+ server_context.raise_if_cancelled!
1170
+
1171
+ do_work(i)
1172
+ end
1173
+
1174
+ MCP::Tool::Response.new([{ type: "text", text: "Done" }])
1175
+ end
1176
+ ```
1177
+
1178
+ When a handler observes cancellation (either by returning early with `cancelled?` or
1179
+ by raising `MCP::CancelledError` via `raise_if_cancelled!`), the server drops the response and
1180
+ no JSON-RPC result is sent to the client.
1181
+
1182
+ The same pattern works for other handler types:
1183
+
1184
+ ```ruby
1185
+ # resources/read
1186
+ server.resources_read_handler do |params, server_context:|
1187
+ server_context.raise_if_cancelled!
1188
+ # read the resource
1189
+ end
1190
+
1191
+ # completion/complete
1192
+ server.completion_handler do |params, server_context:|
1193
+ server_context.raise_if_cancelled!
1194
+ # compute completions
1195
+ end
1196
+
1197
+ # custom method
1198
+ server.define_custom_method(method_name: "custom/slow") do |params, server_context:|
1199
+ server_context.raise_if_cancelled!
1200
+ # do work
1201
+ end
1202
+
1203
+ # prompts (via Prompt subclass)
1204
+ class SlowPrompt < MCP::Prompt
1205
+ prompt_name "slow_prompt"
1206
+
1207
+ def self.template(args, server_context:)
1208
+ server_context.raise_if_cancelled!
1209
+ MCP::Prompt::Result.new(messages: [])
1210
+ end
1211
+ end
1212
+ ```
1213
+
1214
+ Handlers that do not declare a `server_context:` keyword continue to work unchanged -
1215
+ the opt-in detection only wraps the context when the block signature asks for it.
1216
+
1217
+ #### Nested Server-to-Client Requests Are Cancelled Automatically
1218
+
1219
+ When a tool handler is waiting on a nested server-to-client request
1220
+ (`server_context.create_sampling_message`, `create_form_elicitation`, or
1221
+ `create_url_elicitation`), cancelling the parent tool call automatically raises
1222
+ `MCP::CancelledError` from the nested call, so the tool does not need to wrap it
1223
+ in its own `cancelled?` checks:
1224
+
1225
+ ```ruby
1226
+ def self.call(server_context:)
1227
+ result = server_context.create_sampling_message(messages: messages, max_tokens: 100)
1228
+ # If the parent tools/call is cancelled while waiting above, MCP::CancelledError
1229
+ # is raised here and the tool can let it propagate or clean up as needed.
1230
+ MCP::Tool::Response.new([{ type: "text", text: result[:content][:text] }])
1231
+ rescue MCP::CancelledError
1232
+ # Optional: run cleanup. Re-raising (or letting it propagate) is fine; the server
1233
+ # will still suppress the JSON-RPC response per the MCP spec.
1234
+ raise
1235
+ end
1236
+ ```
1237
+
1238
+ Nested cancellation propagation is supported on `StreamableHTTPTransport` only.
1239
+ `StdioTransport` is single-threaded and blocks on `$stdin.gets`, so a nested
1240
+ `server_context.create_sampling_message` inside a tool runs to completion even if
1241
+ the parent `tools/call` is cancelled. The parent tool itself still observes cancellation
1242
+ via `server_context.cancelled?` between nested calls.
1243
+
1102
1244
  ### Ping
1103
1245
 
1104
1246
  The MCP Ruby SDK supports the
@@ -1597,7 +1739,7 @@ This class supports:
1597
1739
  - Tool invocation via the `tools/call` method (`MCP::Client#call_tools`)
1598
1740
  - Resource listing via the `resources/list` method (`MCP::Client#resources`)
1599
1741
  - 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`)
1742
+ - Resource reading via the `resources/read` method (`MCP::Client#read_resource`)
1601
1743
  - Prompt listing via the `prompts/list` method (`MCP::Client#prompts`)
1602
1744
  - Prompt retrieval via the `prompts/get` method (`MCP::Client#get_prompt`)
1603
1745
  - Completion requests via the `completion/complete` method (`MCP::Client#complete`)
@@ -1653,6 +1795,9 @@ stdio_transport = MCP::Client::Stdio.new(
1653
1795
  )
1654
1796
  client = MCP::Client.new(transport: stdio_transport)
1655
1797
 
1798
+ # Perform the MCP initialization handshake before sending any requests.
1799
+ client.connect
1800
+
1656
1801
  # List available tools.
1657
1802
  tools = client.tools
1658
1803
  tools.each do |tool|
@@ -1693,6 +1838,9 @@ Example usage:
1693
1838
  http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp")
1694
1839
  client = MCP::Client.new(transport: http_transport)
1695
1840
 
1841
+ # Perform the MCP initialization handshake before sending any requests.
1842
+ client.connect
1843
+
1696
1844
  # List available tools
1697
1845
  tools = client.tools
1698
1846
  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.
@@ -83,6 +123,7 @@ module MCP
83
123
  name: tool["name"],
84
124
  description: tool["description"],
85
125
  input_schema: tool["inputSchema"],
126
+ output_schema: tool["outputSchema"],
86
127
  )
87
128
  end
88
129