ruby-mcp-client 0.8.0 → 0.9.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.
@@ -9,9 +9,9 @@ require 'faraday/retry'
9
9
  require 'faraday/follow_redirects'
10
10
 
11
11
  module MCPClient
12
- # Implementation of MCP server that communicates via Streamable HTTP transport (MCP 2025-03-26)
12
+ # Implementation of MCP server that communicates via Streamable HTTP transport (MCP 2025-06-18)
13
13
  # This transport uses HTTP POST for RPC calls with optional SSE responses, and GET for event streams
14
- # Compliant with MCP specification version 2025-03-26
14
+ # Compliant with MCP specification version 2025-06-18
15
15
  #
16
16
  # Key features:
17
17
  # - Supports server-sent events (SSE) for real-time notifications
@@ -91,7 +91,7 @@ module MCPClient
91
91
  @headers = opts[:headers].merge({
92
92
  'Content-Type' => 'application/json',
93
93
  'Accept' => 'text/event-stream, application/json',
94
- 'Accept-Encoding' => 'gzip, deflate',
94
+ 'Accept-Encoding' => 'gzip',
95
95
  'User-Agent' => "ruby-mcp-client/#{MCPClient::VERSION}",
96
96
  'Cache-Control' => 'no-cache'
97
97
  })
@@ -116,6 +116,7 @@ module MCPClient
116
116
  @events_connection = nil
117
117
  @events_thread = nil
118
118
  @buffer = '' # Buffer for partial SSE event data
119
+ @elicitation_request_callback = nil # MCP 2025-06-18
119
120
  end
120
121
 
121
122
  # Connect to the MCP server over Streamable HTTP
@@ -262,24 +263,32 @@ module MCPClient
262
263
  end
263
264
 
264
265
  # List all resources available from the MCP server
265
- # @return [Array<MCPClient::Resource>] list of available resources
266
+ # @param cursor [String, nil] optional cursor for pagination
267
+ # @return [Hash] result containing resources array and optional nextCursor
266
268
  # @raise [MCPClient::Errors::ResourceReadError] if resources list retrieval fails
267
- def list_resources
269
+ def list_resources(cursor: nil)
268
270
  @mutex.synchronize do
269
- return @resources if @resources
271
+ return @resources_result if @resources_result && !cursor
270
272
  end
271
273
 
272
274
  begin
273
275
  ensure_connected
274
276
 
275
- resources_data = request_resources_list
277
+ params = {}
278
+ params['cursor'] = cursor if cursor
279
+ result = rpc_request('resources/list', params)
280
+
281
+ resources = (result['resources'] || []).map do |resource_data|
282
+ MCPClient::Resource.from_json(resource_data, server: self)
283
+ end
284
+
285
+ resources_result = { 'resources' => resources, 'nextCursor' => result['nextCursor'] }
286
+
276
287
  @mutex.synchronize do
277
- @resources = resources_data.map do |resource_data|
278
- MCPClient::Resource.from_json(resource_data, server: self)
279
- end
288
+ @resources_result = resources_result unless cursor
280
289
  end
281
290
 
282
- @mutex.synchronize { @resources }
291
+ resources_result
283
292
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
284
293
  # Re-raise these errors directly
285
294
  raise
@@ -290,10 +299,12 @@ module MCPClient
290
299
 
291
300
  # Read a resource by its URI
292
301
  # @param uri [String] the URI of the resource to read
293
- # @return [Object] the resource contents
302
+ # @return [Array<MCPClient::ResourceContent>] array of resource contents
294
303
  # @raise [MCPClient::Errors::ResourceReadError] if resource reading fails
295
304
  def read_resource(uri)
296
- rpc_request('resources/read', { uri: uri })
305
+ result = rpc_request('resources/read', { uri: uri })
306
+ contents = result['contents'] || []
307
+ contents.map { |content| MCPClient::ResourceContent.from_json(content) }
297
308
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
298
309
  # Re-raise connection/transport errors directly
299
310
  raise
@@ -302,6 +313,52 @@ module MCPClient
302
313
  raise MCPClient::Errors::ResourceReadError, "Error reading resource '#{uri}': #{e.message}"
303
314
  end
304
315
 
316
+ # List all resource templates available from the MCP server
317
+ # @param cursor [String, nil] optional cursor for pagination
318
+ # @return [Hash] result containing resourceTemplates array and optional nextCursor
319
+ # @raise [MCPClient::Errors::ResourceReadError] for other errors during resource template listing
320
+ def list_resource_templates(cursor: nil)
321
+ params = {}
322
+ params['cursor'] = cursor if cursor
323
+ result = rpc_request('resources/templates/list', params)
324
+
325
+ templates = (result['resourceTemplates'] || []).map do |template_data|
326
+ MCPClient::ResourceTemplate.from_json(template_data, server: self)
327
+ end
328
+
329
+ { 'resourceTemplates' => templates, 'nextCursor' => result['nextCursor'] }
330
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
331
+ raise
332
+ rescue StandardError => e
333
+ raise MCPClient::Errors::ResourceReadError, "Error listing resource templates: #{e.message}"
334
+ end
335
+
336
+ # Subscribe to resource updates
337
+ # @param uri [String] the URI of the resource to subscribe to
338
+ # @return [Boolean] true if subscription successful
339
+ # @raise [MCPClient::Errors::ResourceReadError] for other errors during subscription
340
+ def subscribe_resource(uri)
341
+ rpc_request('resources/subscribe', { uri: uri })
342
+ true
343
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
344
+ raise
345
+ rescue StandardError => e
346
+ raise MCPClient::Errors::ResourceReadError, "Error subscribing to resource '#{uri}': #{e.message}"
347
+ end
348
+
349
+ # Unsubscribe from resource updates
350
+ # @param uri [String] the URI of the resource to unsubscribe from
351
+ # @return [Boolean] true if unsubscription successful
352
+ # @raise [MCPClient::Errors::ResourceReadError] for other errors during unsubscription
353
+ def unsubscribe_resource(uri)
354
+ rpc_request('resources/unsubscribe', { uri: uri })
355
+ true
356
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
357
+ raise
358
+ rescue StandardError => e
359
+ raise MCPClient::Errors::ResourceReadError, "Error unsubscribing from resource '#{uri}': #{e.message}"
360
+ end
361
+
305
362
  # Override apply_request_headers to add session and SSE headers for MCP protocol
306
363
  def apply_request_headers(req, request)
307
364
  super
@@ -395,6 +452,13 @@ module MCPClient
395
452
  end
396
453
  end
397
454
 
455
+ # Register a callback for elicitation requests (MCP 2025-06-18)
456
+ # @param block [Proc] callback that receives (request_id, params) and returns response hash
457
+ # @return [void]
458
+ def on_elicitation_request(&block)
459
+ @elicitation_request_callback = block
460
+ end
461
+
398
462
  private
399
463
 
400
464
  def perform_initialize
@@ -699,12 +763,12 @@ module MCPClient
699
763
  # Handle ping requests from server (keepalive mechanism)
700
764
  if message['method'] == 'ping' && message.key?('id')
701
765
  handle_ping_request(message['id'])
766
+ elsif message['method'] && message.key?('id')
767
+ # Handle server-to-client requests (MCP 2025-06-18)
768
+ handle_server_request(message)
702
769
  elsif message['method'] && !message.key?('id')
703
770
  # Handle server notifications (messages without id)
704
771
  @notification_callback&.call(message['method'], message['params'])
705
- elsif message.key?('id')
706
- # This might be a server-to-client request (future MCP versions)
707
- @logger.warn("Received unhandled server request: #{message['method']}")
708
772
  end
709
773
  rescue JSON::ParserError => e
710
774
  @logger.error("Invalid JSON in server message: #{e.message}")
@@ -740,5 +804,123 @@ module MCPClient
740
804
  @logger.error("Failed to send pong response: #{e.message}")
741
805
  end
742
806
  end
807
+
808
+ # Handle incoming JSON-RPC request from server (MCP 2025-06-18)
809
+ # @param msg [Hash] the JSON-RPC request message
810
+ # @return [void]
811
+ def handle_server_request(msg)
812
+ request_id = msg['id']
813
+ method = msg['method']
814
+ params = msg['params'] || {}
815
+
816
+ @logger.debug("Received server request: #{method} (id: #{request_id})")
817
+
818
+ case method
819
+ when 'elicitation/create'
820
+ handle_elicitation_create(request_id, params)
821
+ else
822
+ # Unknown request method, send error response
823
+ send_error_response(request_id, -32_601, "Method not found: #{method}")
824
+ end
825
+ rescue StandardError => e
826
+ @logger.error("Error handling server request: #{e.message}")
827
+ send_error_response(request_id, -32_603, "Internal error: #{e.message}")
828
+ end
829
+
830
+ # Handle elicitation/create request from server (MCP 2025-06-18)
831
+ # @param request_id [String, Integer] the JSON-RPC request ID (used as elicitationId)
832
+ # @param params [Hash] the elicitation parameters
833
+ # @return [void]
834
+ def handle_elicitation_create(request_id, params)
835
+ # The request_id is the elicitationId per MCP spec
836
+ elicitation_id = request_id
837
+
838
+ # If no callback is registered, decline the request
839
+ unless @elicitation_request_callback
840
+ @logger.warn('Received elicitation request but no callback registered, declining')
841
+ send_elicitation_response(elicitation_id, { 'action' => 'decline' })
842
+ return
843
+ end
844
+
845
+ # Call the registered callback
846
+ result = @elicitation_request_callback.call(request_id, params)
847
+
848
+ # Send the response back to the server
849
+ send_elicitation_response(elicitation_id, result)
850
+ end
851
+
852
+ # Send elicitation response back to server via HTTP POST (MCP 2025-06-18)
853
+ # For streamable HTTP, this is sent as a JSON-RPC request (not response)
854
+ # because HTTP is unidirectional.
855
+ # @param elicitation_id [String] the elicitation ID from the server
856
+ # @param result [Hash] the elicitation result (action and optional content)
857
+ # @return [void]
858
+ def send_elicitation_response(elicitation_id, result)
859
+ params = {
860
+ 'elicitationId' => elicitation_id,
861
+ 'action' => result['action']
862
+ }
863
+
864
+ # Only include content if present (typically for 'accept' action)
865
+ params['content'] = result['content'] if result['content']
866
+
867
+ request = {
868
+ 'jsonrpc' => '2.0',
869
+ 'method' => 'elicitation/response',
870
+ 'params' => params
871
+ }
872
+
873
+ # Send as a JSON-RPC request via HTTP POST
874
+ post_jsonrpc_response(request)
875
+ rescue StandardError => e
876
+ @logger.error("Error sending elicitation response: #{e.message}")
877
+ end
878
+
879
+ # Send error response back to server via HTTP POST (MCP 2025-06-18)
880
+ # @param request_id [String, Integer] the JSON-RPC request ID
881
+ # @param code [Integer] the error code
882
+ # @param message [String] the error message
883
+ # @return [void]
884
+ def send_error_response(request_id, code, message)
885
+ response = {
886
+ 'jsonrpc' => '2.0',
887
+ 'id' => request_id,
888
+ 'error' => {
889
+ 'code' => code,
890
+ 'message' => message
891
+ }
892
+ }
893
+
894
+ # Send response via HTTP POST to the endpoint
895
+ post_jsonrpc_response(response)
896
+ rescue StandardError => e
897
+ @logger.error("Error sending error response: #{e.message}")
898
+ end
899
+
900
+ # Post a JSON-RPC response message to the server via HTTP
901
+ # @param response [Hash] the JSON-RPC response
902
+ # @return [void]
903
+ # @private
904
+ def post_jsonrpc_response(response)
905
+ # Send response in a separate thread to avoid blocking event processing
906
+ Thread.new do
907
+ conn = http_connection
908
+ json_body = JSON.generate(response)
909
+
910
+ resp = conn.post(@endpoint) do |req|
911
+ @headers.each { |k, v| req.headers[k] = v }
912
+ req.headers['Mcp-Session-Id'] = @session_id if @session_id
913
+ req.body = json_body
914
+ end
915
+
916
+ if resp.success?
917
+ @logger.debug("Sent JSON-RPC response: #{json_body}")
918
+ else
919
+ @logger.warn("Failed to send JSON-RPC response: HTTP #{resp.status}")
920
+ end
921
+ rescue StandardError => e
922
+ @logger.error("Failed to send JSON-RPC response: #{e.message}")
923
+ end
924
+ end
743
925
  end
744
926
  end
@@ -8,20 +8,28 @@ module MCPClient
8
8
  # @!attribute [r] description
9
9
  # @return [String] the description of the tool
10
10
  # @!attribute [r] schema
11
- # @return [Hash] the JSON schema for the tool
11
+ # @return [Hash] the JSON schema for the tool inputs
12
+ # @!attribute [r] output_schema
13
+ # @return [Hash, nil] optional JSON schema for structured tool outputs (MCP 2025-06-18)
14
+ # @!attribute [r] annotations
15
+ # @return [Hash, nil] optional annotations describing tool behavior (e.g., readOnly, destructive)
12
16
  # @!attribute [r] server
13
17
  # @return [MCPClient::ServerBase, nil] the server this tool belongs to
14
- attr_reader :name, :description, :schema, :server
18
+ attr_reader :name, :description, :schema, :output_schema, :annotations, :server
15
19
 
16
20
  # Initialize a new Tool
17
21
  # @param name [String] the name of the tool
18
22
  # @param description [String] the description of the tool
19
- # @param schema [Hash] the JSON schema for the tool
23
+ # @param schema [Hash] the JSON schema for the tool inputs
24
+ # @param output_schema [Hash, nil] optional JSON schema for structured tool outputs (MCP 2025-06-18)
25
+ # @param annotations [Hash, nil] optional annotations describing tool behavior
20
26
  # @param server [MCPClient::ServerBase, nil] the server this tool belongs to
21
- def initialize(name:, description:, schema:, server: nil)
27
+ def initialize(name:, description:, schema:, output_schema: nil, annotations: nil, server: nil)
22
28
  @name = name
23
29
  @description = description
24
30
  @schema = schema
31
+ @output_schema = output_schema
32
+ @annotations = annotations
25
33
  @server = server
26
34
  end
27
35
 
@@ -33,10 +41,14 @@ module MCPClient
33
41
  # Some servers (Playwright MCP CLI) use 'inputSchema' instead of 'schema'
34
42
  # Handle both string and symbol keys
35
43
  schema = data['inputSchema'] || data[:inputSchema] || data['schema'] || data[:schema]
44
+ output_schema = data['outputSchema'] || data[:outputSchema]
45
+ annotations = data['annotations'] || data[:annotations]
36
46
  new(
37
47
  name: data['name'] || data[:name],
38
48
  description: data['description'] || data[:description],
39
49
  schema: schema,
50
+ output_schema: output_schema,
51
+ annotations: annotations,
40
52
  server: server
41
53
  )
42
54
  end
@@ -74,6 +86,30 @@ module MCPClient
74
86
  }
75
87
  end
76
88
 
89
+ # Check if the tool is marked as read-only
90
+ # @return [Boolean] true if the tool is read-only
91
+ def read_only?
92
+ @annotations && @annotations['readOnly'] == true
93
+ end
94
+
95
+ # Check if the tool is marked as destructive
96
+ # @return [Boolean] true if the tool is destructive
97
+ def destructive?
98
+ @annotations && @annotations['destructive'] == true
99
+ end
100
+
101
+ # Check if the tool requires confirmation before execution
102
+ # @return [Boolean] true if the tool requires confirmation
103
+ def requires_confirmation?
104
+ @annotations && @annotations['requiresConfirmation'] == true
105
+ end
106
+
107
+ # Check if the tool supports structured outputs (MCP 2025-06-18)
108
+ # @return [Boolean] true if the tool has an output schema defined
109
+ def structured_output?
110
+ !@output_schema.nil? && !@output_schema.empty?
111
+ end
112
+
77
113
  private
78
114
 
79
115
  # Recursively remove "$schema" keys that are not accepted by Vertex AI
@@ -2,8 +2,8 @@
2
2
 
3
3
  module MCPClient
4
4
  # Current version of the MCP client gem
5
- VERSION = '0.8.0'
5
+ VERSION = '0.9.0'
6
6
 
7
7
  # MCP protocol version (date-based) - unified across all transports
8
- PROTOCOL_VERSION = '2025-03-26'
8
+ PROTOCOL_VERSION = '2025-06-18'
9
9
  end
data/lib/mcp_client.rb CHANGED
@@ -5,6 +5,8 @@ require_relative 'mcp_client/errors'
5
5
  require_relative 'mcp_client/tool'
6
6
  require_relative 'mcp_client/prompt'
7
7
  require_relative 'mcp_client/resource'
8
+ require_relative 'mcp_client/resource_template'
9
+ require_relative 'mcp_client/resource_content'
8
10
  require_relative 'mcp_client/server_base'
9
11
  require_relative 'mcp_client/server_stdio'
10
12
  require_relative 'mcp_client/server_sse'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-mcp-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Szymon Kurcab
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-09-16 00:00:00.000000000 Z
11
+ date: 2025-11-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -120,6 +120,7 @@ files:
120
120
  - README.md
121
121
  - lib/mcp_client.rb
122
122
  - lib/mcp_client/auth.rb
123
+ - lib/mcp_client/auth/browser_oauth.rb
123
124
  - lib/mcp_client/auth/oauth_provider.rb
124
125
  - lib/mcp_client/client.rb
125
126
  - lib/mcp_client/config_parser.rb
@@ -129,6 +130,8 @@ files:
129
130
  - lib/mcp_client/oauth_client.rb
130
131
  - lib/mcp_client/prompt.rb
131
132
  - lib/mcp_client/resource.rb
133
+ - lib/mcp_client/resource_content.rb
134
+ - lib/mcp_client/resource_template.rb
132
135
  - lib/mcp_client/server_base.rb
133
136
  - lib/mcp_client/server_factory.rb
134
137
  - lib/mcp_client/server_http.rb