mcp 0.13.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.
@@ -1,25 +1,134 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+ require_relative "../../json_rpc_handler"
5
+ require_relative "../configuration"
6
+ require_relative "../methods"
7
+ require_relative "../version"
8
+
3
9
  module MCP
4
10
  class Client
11
+ # TODO: HTTP GET for SSE streaming is not yet implemented.
12
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server
13
+ # TODO: Resumability and redelivery with Last-Event-ID is not yet implemented.
14
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery
5
15
  class HTTP
6
16
  ACCEPT_HEADER = "application/json, text/event-stream"
17
+ SESSION_ID_HEADER = "Mcp-Session-Id"
18
+ PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
7
19
 
8
- attr_reader :url
20
+ attr_reader :url, :session_id, :protocol_version, :server_info
9
21
 
10
22
  def initialize(url:, headers: {}, &block)
11
23
  @url = url
12
24
  @headers = headers
13
25
  @faraday_customizer = block
26
+ @session_id = nil
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
14
116
  end
15
117
 
118
+ # Sends a JSON-RPC request and returns the parsed response body.
119
+ # After a successful `initialize` handshake, the session ID and protocol
120
+ # version returned by the server are captured and automatically included
121
+ # on subsequent requests.
16
122
  def send_request(request:)
17
123
  method = request[:method] || request["method"]
18
124
  params = request[:params] || request["params"]
19
125
 
20
- response = client.post("", request)
21
- validate_response_content_type!(response, method, params)
22
- response.body
126
+ response = client.post("", request, session_headers)
127
+ body = parse_response_body(response, method, params)
128
+
129
+ capture_session_info(method, response, body)
130
+
131
+ body
23
132
  rescue Faraday::BadRequestError => e
24
133
  raise RequestHandlerError.new(
25
134
  "The #{method} request is invalid",
@@ -42,12 +151,25 @@ module MCP
42
151
  original_error: e,
43
152
  )
44
153
  rescue Faraday::ResourceNotFound => e
45
- raise RequestHandlerError.new(
46
- "The #{method} request is not found",
47
- { method: method, params: params },
48
- error_type: :not_found,
49
- original_error: e,
50
- )
154
+ # Per spec, 404 is the session-expired signal only when the request
155
+ # actually carried an `Mcp-Session-Id`. A 404 without a session attached
156
+ # (e.g. wrong URL or a stateless server) surfaces as a generic not-found.
157
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
158
+ if @session_id
159
+ clear_session
160
+ raise SessionExpiredError.new(
161
+ "The #{method} request is not found",
162
+ { method: method, params: params },
163
+ original_error: e,
164
+ )
165
+ else
166
+ raise RequestHandlerError.new(
167
+ "The #{method} request is not found",
168
+ { method: method, params: params },
169
+ error_type: :not_found,
170
+ original_error: e,
171
+ )
172
+ end
51
173
  rescue Faraday::UnprocessableEntityError => e
52
174
  raise RequestHandlerError.new(
53
175
  "The #{method} request is unprocessable",
@@ -64,6 +186,31 @@ module MCP
64
186
  )
65
187
  end
66
188
 
189
+ # Terminates the session by sending an HTTP DELETE to the MCP endpoint
190
+ # with the current `Mcp-Session-Id` header, and clears locally tracked
191
+ # session state afterward. No-op when no session has been established.
192
+ #
193
+ # Per spec, the server MAY respond with HTTP 405 Method Not Allowed when
194
+ # it does not support client-initiated termination, and returns 404 for
195
+ # a session it has already terminated. Both mean the session is gone —
196
+ # the desired end state. Other errors surface to the caller; local
197
+ # session state is cleared either way.
198
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
199
+ def close
200
+ unless @session_id
201
+ clear_session
202
+ return
203
+ end
204
+
205
+ begin
206
+ client.delete("", nil, session_headers)
207
+ rescue Faraday::ClientError => e
208
+ raise unless [404, 405].include?(e.response&.dig(:status))
209
+ ensure
210
+ clear_session
211
+ end
212
+ end
213
+
67
214
  private
68
215
 
69
216
  attr_reader :headers
@@ -84,6 +231,33 @@ module MCP
84
231
  end
85
232
  end
86
233
 
234
+ # Per spec, the client MUST include `MCP-Session-Id` (when the server assigned one)
235
+ # and `MCP-Protocol-Version` on all requests after `initialize`.
236
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
237
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header
238
+ def session_headers
239
+ request_headers = {}
240
+ request_headers[SESSION_ID_HEADER] = @session_id if @session_id
241
+ request_headers[PROTOCOL_VERSION_HEADER] = @protocol_version if @protocol_version
242
+ request_headers
243
+ end
244
+
245
+ def capture_session_info(method, response, body)
246
+ return unless method.to_s == Methods::INITIALIZE
247
+
248
+ # Faraday normalizes header names to lowercase.
249
+ session_id = response.headers[SESSION_ID_HEADER.downcase]
250
+ @session_id ||= session_id unless session_id.to_s.empty?
251
+ @protocol_version ||= body.is_a?(Hash) ? body.dig("result", "protocolVersion") : nil
252
+ end
253
+
254
+ def clear_session
255
+ @session_id = nil
256
+ @protocol_version = nil
257
+ @server_info = nil
258
+ @connected = false
259
+ end
260
+
87
261
  def require_faraday!
88
262
  require "faraday"
89
263
  rescue LoadError
@@ -92,14 +266,56 @@ module MCP
92
266
  "See https://rubygems.org/gems/faraday for more details."
93
267
  end
94
268
 
95
- def validate_response_content_type!(response, method, params)
269
+ def require_event_stream_parser!
270
+ require "event_stream_parser"
271
+ rescue LoadError
272
+ raise LoadError, "The 'event_stream_parser' gem is required to parse SSE responses. " \
273
+ "Add it to your Gemfile: gem 'event_stream_parser', '>= 1.0'. " \
274
+ "See https://rubygems.org/gems/event_stream_parser for more details."
275
+ end
276
+
277
+ def parse_response_body(response, method, params)
278
+ # 202 Accepted is the server's ACK for a JSON-RPC notification or response; no body is expected.
279
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
280
+ return if response.status == 202
281
+
96
282
  content_type = response.headers["Content-Type"]
97
- return if content_type&.include?("application/json")
283
+
284
+ if content_type&.include?("text/event-stream")
285
+ parse_sse_response(response.body, method, params)
286
+ elsif content_type&.include?("application/json")
287
+ response.body
288
+ else
289
+ raise RequestHandlerError.new(
290
+ "Unsupported Content-Type: #{content_type.inspect}. Expected application/json or text/event-stream.",
291
+ { method: method, params: params },
292
+ error_type: :unsupported_media_type,
293
+ )
294
+ end
295
+ end
296
+
297
+ def parse_sse_response(body, method, params)
298
+ require_event_stream_parser!
299
+
300
+ json_rpc_response = nil
301
+ parser = EventStreamParser::Parser.new
302
+ parser.feed(body.to_s) do |_type, data, _id|
303
+ next if data.empty?
304
+
305
+ begin
306
+ parsed = JSON.parse(data)
307
+ json_rpc_response = parsed if parsed.is_a?(Hash) && (parsed.key?("result") || parsed.key?("error"))
308
+ rescue JSON::ParserError
309
+ next
310
+ end
311
+ end
312
+
313
+ return json_rpc_response if json_rpc_response
98
314
 
99
315
  raise RequestHandlerError.new(
100
- "Unsupported Content-Type: #{content_type.inspect}. This client only supports JSON responses.",
316
+ "No valid JSON-RPC response found in SSE stream",
101
317
  { method: method, params: params },
102
- error_type: :unsupported_media_type,
318
+ error_type: :parse_error,
103
319
  )
104
320
  end
105
321
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Client
5
+ # Result objects returned by `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`.
6
+ # Each carries the page items, an optional opaque `next_cursor` string for continuing pagination,
7
+ # and an optional `meta` hash mirroring the MCP `_meta` response field.
8
+ ListToolsResult = Struct.new(:tools, :next_cursor, :meta, keyword_init: true)
9
+ ListPromptsResult = Struct.new(:prompts, :next_cursor, :meta, keyword_init: true)
10
+ ListResourcesResult = Struct.new(:resources, :next_cursor, :meta, keyword_init: true)
11
+ ListResourceTemplatesResult = Struct.new(:resource_templates, :next_cursor, :meta, keyword_init: true)
12
+ end
13
+ end
@@ -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)