ruby-mcp-client 0.9.1 → 1.0.1

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.
@@ -58,6 +58,7 @@ module MCPClient
58
58
  # Apply base headers but prioritize session termination headers
59
59
  @headers.each { |k, v| req.headers[k] = v }
60
60
  req.headers['Mcp-Session-Id'] = @session_id
61
+ req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
61
62
  end
62
63
 
63
64
  if response.success?
@@ -138,6 +139,7 @@ module MCPClient
138
139
 
139
140
  @server_info = result['serverInfo']
140
141
  @capabilities = result['capabilities']
142
+ @protocol_version = result['protocolVersion']
141
143
  end
142
144
 
143
145
  # Send a JSON-RPC request to the server and wait for result
@@ -63,9 +63,9 @@ module MCPClient
63
63
  # @return [Hash] the initialization parameters
64
64
  def initialization_params
65
65
  capabilities = {
66
- 'elicitation' => {}, # MCP 2025-06-18: Support for server-initiated user interactions
67
- 'roots' => { 'listChanged' => true }, # MCP 2025-06-18: Support for roots
68
- 'sampling' => {} # MCP 2025-06-18: Support for server-initiated LLM sampling
66
+ 'elicitation' => {}, # MCP 2025-11-25: Support for server-initiated user interactions
67
+ 'roots' => { 'listChanged' => true }, # MCP 2025-11-25: Support for roots
68
+ 'sampling' => {} # MCP 2025-11-25: Support for server-initiated LLM sampling
69
69
  }
70
70
 
71
71
  {
@@ -41,6 +41,14 @@ module MCPClient
41
41
  @server = server
42
42
  end
43
43
 
44
+ # Return the lastModified annotation value (ISO 8601 timestamp string)
45
+ # @return [String, nil] the lastModified timestamp, or nil if not set
46
+ def last_modified
47
+ return nil unless @annotations.is_a?(Hash)
48
+
49
+ @annotations['lastModified'] || @annotations[:lastModified]
50
+ end
51
+
44
52
  # Create a Resource instance from JSON data
45
53
  # @param data [Hash] JSON data from MCP server
46
54
  # @param server [MCPClient::ServerBase, nil] the server this resource belongs to
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ # Representation of an MCP resource link in tool result content
5
+ # A resource link references a server resource that can be read separately.
6
+ # Used in tool results to point clients to available resources (MCP 2025-11-25).
7
+ class ResourceLink
8
+ # @!attribute [r] uri
9
+ # @return [String] URI of the linked resource
10
+ # @!attribute [r] name
11
+ # @return [String] the name of the linked resource
12
+ # @!attribute [r] description
13
+ # @return [String, nil] optional human-readable description
14
+ # @!attribute [r] mime_type
15
+ # @return [String, nil] optional MIME type of the resource
16
+ # @!attribute [r] annotations
17
+ # @return [Hash, nil] optional annotations that provide hints to clients
18
+ # @!attribute [r] title
19
+ # @return [String, nil] optional display title for the resource
20
+ # @!attribute [r] size
21
+ # @return [Integer, nil] optional size of the resource in bytes
22
+ attr_reader :uri, :name, :description, :mime_type, :annotations, :title, :size
23
+
24
+ # Initialize a resource link
25
+ # @param uri [String] URI of the linked resource
26
+ # @param name [String] the name of the linked resource
27
+ # @param description [String, nil] optional human-readable description
28
+ # @param mime_type [String, nil] optional MIME type of the resource
29
+ # @param annotations [Hash, nil] optional annotations that provide hints to clients
30
+ # @param title [String, nil] optional display title for the resource
31
+ # @param size [Integer, nil] optional size of the resource in bytes
32
+ def initialize(uri:, name:, description: nil, mime_type: nil, annotations: nil, title: nil, size: nil)
33
+ @uri = uri
34
+ @name = name
35
+ @description = description
36
+ @mime_type = mime_type
37
+ @annotations = annotations
38
+ @title = title
39
+ @size = size
40
+ end
41
+
42
+ # Create a ResourceLink instance from JSON data
43
+ # @param data [Hash] JSON data from MCP server (content item with type 'resource_link')
44
+ # @return [MCPClient::ResourceLink] resource link instance
45
+ def self.from_json(data)
46
+ new(
47
+ uri: data['uri'],
48
+ name: data['name'],
49
+ description: data['description'],
50
+ mime_type: data['mimeType'],
51
+ annotations: data['annotations'],
52
+ title: data['title'],
53
+ size: data['size']
54
+ )
55
+ end
56
+
57
+ # The content type identifier for this content type
58
+ # @return [String] 'resource_link'
59
+ def type
60
+ 'resource_link'
61
+ end
62
+ end
63
+ end
@@ -195,15 +195,22 @@ module MCPClient
195
195
  raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
196
196
  end
197
197
 
198
- # Override apply_request_headers to add session headers for MCP protocol
198
+ # Override apply_request_headers to add session and protocol version headers
199
199
  def apply_request_headers(req, request)
200
200
  super
201
201
 
202
- # Add session header if we have one (for non-initialize requests)
203
- return unless @session_id && request['method'] != 'initialize'
202
+ # Add session and protocol version headers for non-initialize requests
203
+ return unless request['method'] != 'initialize'
204
204
 
205
- req.headers['Mcp-Session-Id'] = @session_id
206
- @logger.debug("Adding session header: Mcp-Session-Id: #{@session_id}")
205
+ if @session_id
206
+ req.headers['Mcp-Session-Id'] = @session_id
207
+ @logger.debug("Adding session header: Mcp-Session-Id: #{@session_id}")
208
+ end
209
+
210
+ return unless @protocol_version
211
+
212
+ req.headers['Mcp-Protocol-Version'] = @protocol_version
213
+ @logger.debug("Adding protocol version header: Mcp-Protocol-Version: #{@protocol_version}")
207
214
  end
208
215
 
209
216
  # Override handle_successful_response to capture session ID
@@ -325,10 +332,13 @@ module MCPClient
325
332
  # Request completion suggestions from the server (MCP 2025-06-18)
326
333
  # @param ref [Hash] reference to complete (prompt or resource)
327
334
  # @param argument [Hash] the argument being completed (e.g., { 'name' => 'arg_name', 'value' => 'partial' })
335
+ # @param context [Hash, nil] optional context for the completion (MCP 2025-11-25)
328
336
  # @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
329
337
  # @raise [MCPClient::Errors::ServerError] if server returns an error
330
- def complete(ref:, argument:)
331
- result = rpc_request('completion/complete', { ref: ref, argument: argument })
338
+ def complete(ref:, argument:, context: nil)
339
+ params = { ref: ref, argument: argument }
340
+ params[:context] = context if context
341
+ result = rpc_request('completion/complete', params)
332
342
  result['completion'] || { 'values' => [] }
333
343
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
334
344
  raise
@@ -106,7 +106,7 @@ module MCPClient
106
106
  @activity_timer_thread = nil
107
107
  @elicitation_request_callback = nil # MCP 2025-06-18
108
108
  @roots_list_request_callback = nil # MCP 2025-06-18
109
- @sampling_request_callback = nil # MCP 2025-06-18
109
+ @sampling_request_callback = nil # MCP 2025-11-25
110
110
  end
111
111
 
112
112
  # Stream tool call fallback for SSE transport (yields single result)
@@ -330,10 +330,13 @@ module MCPClient
330
330
  # Request completion suggestions from the server (MCP 2025-06-18)
331
331
  # @param ref [Hash] reference object (e.g., { 'type' => 'ref/prompt', 'name' => 'prompt_name' })
332
332
  # @param argument [Hash] the argument being completed (e.g., { 'name' => 'arg_name', 'value' => 'partial' })
333
+ # @param context [Hash, nil] optional context for the completion (MCP 2025-11-25)
333
334
  # @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
334
335
  # @raise [MCPClient::Errors::ServerError] if server returns an error
335
- def complete(ref:, argument:)
336
- result = rpc_request('completion/complete', { ref: ref, argument: argument })
336
+ def complete(ref:, argument:, context: nil)
337
+ params = { ref: ref, argument: argument }
338
+ params[:context] = context if context
339
+ result = rpc_request('completion/complete', params)
337
340
  result['completion'] || { 'values' => [] }
338
341
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
339
342
  raise
@@ -455,7 +458,7 @@ module MCPClient
455
458
  @roots_list_request_callback = block
456
459
  end
457
460
 
458
- # Register a callback for sampling requests (MCP 2025-06-18)
461
+ # Register a callback for sampling requests (MCP 2025-11-25)
459
462
  # @param block [Proc] callback that receives (request_id, params) and returns response hash
460
463
  # @return [void]
461
464
  def on_sampling_request(&block)
@@ -545,7 +548,7 @@ module MCPClient
545
548
  @logger.error("Error sending roots/list response: #{e.message}")
546
549
  end
547
550
 
548
- # Handle sampling/createMessage request from server (MCP 2025-06-18)
551
+ # Handle sampling/createMessage request from server (MCP 2025-11-25)
549
552
  # @param request_id [String, Integer] the JSON-RPC request ID
550
553
  # @param params [Hash] the sampling parameters
551
554
  # @return [void]
@@ -564,7 +567,7 @@ module MCPClient
564
567
  send_sampling_response(request_id, result)
565
568
  end
566
569
 
567
- # Send sampling response back to server via HTTP POST (MCP 2025-06-18)
570
+ # Send sampling response back to server via HTTP POST (MCP 2025-11-25)
568
571
  # @param request_id [String, Integer] the JSON-RPC request ID
569
572
  # @param result [Hash] the sampling result (role, content, model, stopReason)
570
573
  # @return [void]
@@ -48,7 +48,7 @@ module MCPClient
48
48
  @env = env || {}
49
49
  @elicitation_request_callback = nil # MCP 2025-06-18
50
50
  @roots_list_request_callback = nil # MCP 2025-06-18
51
- @sampling_request_callback = nil # MCP 2025-06-18
51
+ @sampling_request_callback = nil # MCP 2025-11-25
52
52
  end
53
53
 
54
54
  # Server info from the initialize response
@@ -345,16 +345,19 @@ module MCPClient
345
345
  # Request completion suggestions from the server (MCP 2025-06-18)
346
346
  # @param ref [Hash] reference object (e.g., { 'type' => 'ref/prompt', 'name' => 'prompt_name' })
347
347
  # @param argument [Hash] the argument being completed (e.g., { 'name' => 'arg_name', 'value' => 'partial' })
348
+ # @param context [Hash, nil] optional context for the completion (MCP 2025-11-25)
348
349
  # @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
349
350
  # @raise [MCPClient::Errors::ServerError] if server returns an error
350
- def complete(ref:, argument:)
351
+ def complete(ref:, argument:, context: nil)
351
352
  ensure_initialized
352
353
  req_id = next_id
354
+ params = { 'ref' => ref, 'argument' => argument }
355
+ params['context'] = context if context
353
356
  req = {
354
357
  'jsonrpc' => '2.0',
355
358
  'id' => req_id,
356
359
  'method' => 'completion/complete',
357
- 'params' => { 'ref' => ref, 'argument' => argument }
360
+ 'params' => params
358
361
  }
359
362
  send_request(req)
360
363
  res = wait_response(req_id)
@@ -406,7 +409,7 @@ module MCPClient
406
409
  @roots_list_request_callback = block
407
410
  end
408
411
 
409
- # Register a callback for sampling requests (MCP 2025-06-18)
412
+ # Register a callback for sampling requests (MCP 2025-11-25)
410
413
  # @param block [Proc] callback that receives (request_id, params) and returns response hash
411
414
  # @return [void]
412
415
  def on_sampling_request(&block)
@@ -477,7 +480,7 @@ module MCPClient
477
480
  send_roots_list_response(request_id, result)
478
481
  end
479
482
 
480
- # Handle sampling/createMessage request from server (MCP 2025-06-18)
483
+ # Handle sampling/createMessage request from server (MCP 2025-11-25)
481
484
  # @param request_id [String, Integer] the JSON-RPC request ID
482
485
  # @param params [Hash] the sampling parameters
483
486
  # @return [void]
@@ -509,7 +512,7 @@ module MCPClient
509
512
  send_message(response)
510
513
  end
511
514
 
512
- # Send sampling response back to server (MCP 2025-06-18)
515
+ # Send sampling response back to server (MCP 2025-11-25)
513
516
  # @param request_id [String, Integer] the JSON-RPC request ID
514
517
  # @param result [Hash] the sampling result (role, content, model, stopReason)
515
518
  # @return [void]
@@ -119,7 +119,7 @@ module MCPClient
119
119
  @buffer = '' # Buffer for partial SSE event data
120
120
  @elicitation_request_callback = nil # MCP 2025-06-18
121
121
  @roots_list_request_callback = nil # MCP 2025-06-18
122
- @sampling_request_callback = nil # MCP 2025-06-18
122
+ @sampling_request_callback = nil # MCP 2025-11-25
123
123
  end
124
124
 
125
125
  # Connect to the MCP server over Streamable HTTP
@@ -222,10 +222,13 @@ module MCPClient
222
222
  # Request completion suggestions from the server (MCP 2025-06-18)
223
223
  # @param ref [Hash] reference object (e.g., { 'type' => 'ref/prompt', 'name' => 'prompt_name' })
224
224
  # @param argument [Hash] the argument being completed (e.g., { 'name' => 'arg_name', 'value' => 'partial' })
225
+ # @param context [Hash, nil] optional context for the completion (MCP 2025-11-25)
225
226
  # @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
226
227
  # @raise [MCPClient::Errors::ServerError] if server returns an error
227
- def complete(ref:, argument:)
228
- result = rpc_request('completion/complete', { ref: ref, argument: argument })
228
+ def complete(ref:, argument:, context: nil)
229
+ params = { ref: ref, argument: argument }
230
+ params[:context] = context if context
231
+ result = rpc_request('completion/complete', params)
229
232
  result['completion'] || { 'values' => [] }
230
233
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
231
234
  raise
@@ -393,10 +396,17 @@ module MCPClient
393
396
  def apply_request_headers(req, request)
394
397
  super
395
398
 
396
- # Add session header if we have one (for non-initialize requests)
397
- if @session_id && request['method'] != 'initialize'
398
- req.headers['Mcp-Session-Id'] = @session_id
399
- @logger.debug("Adding session header: Mcp-Session-Id: #{@session_id}")
399
+ # Add session and protocol version headers for non-initialize requests
400
+ if request['method'] != 'initialize'
401
+ if @session_id
402
+ req.headers['Mcp-Session-Id'] = @session_id
403
+ @logger.debug("Adding session header: Mcp-Session-Id: #{@session_id}")
404
+ end
405
+
406
+ if @protocol_version
407
+ req.headers['Mcp-Protocol-Version'] = @protocol_version
408
+ @logger.debug("Adding protocol version header: Mcp-Protocol-Version: #{@protocol_version}")
409
+ end
400
410
  end
401
411
 
402
412
  # Add Last-Event-ID header for resumability (if available)
@@ -496,7 +506,7 @@ module MCPClient
496
506
  @roots_list_request_callback = block
497
507
  end
498
508
 
499
- # Register a callback for sampling requests (MCP 2025-06-18)
509
+ # Register a callback for sampling requests (MCP 2025-11-25)
500
510
  # @param block [Proc] callback that receives (request_id, params) and returns response hash
501
511
  # @return [void]
502
512
  def on_sampling_request(&block)
@@ -730,6 +740,7 @@ module MCPClient
730
740
  def apply_events_headers(req)
731
741
  @headers.each { |k, v| req.headers[k] = v }
732
742
  req.headers['Mcp-Session-Id'] = @session_id if @session_id
743
+ req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
733
744
  end
734
745
 
735
746
  # Process event chunks from the server
@@ -840,6 +851,7 @@ module MCPClient
840
851
  response = conn.post(@endpoint) do |req|
841
852
  @headers.each { |k, v| req.headers[k] = v }
842
853
  req.headers['Mcp-Session-Id'] = @session_id if @session_id
854
+ req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
843
855
  req.body = pong_response.to_json
844
856
  end
845
857
 
@@ -920,7 +932,7 @@ module MCPClient
920
932
  send_roots_list_response(request_id, result)
921
933
  end
922
934
 
923
- # Handle sampling/createMessage request from server (MCP 2025-06-18)
935
+ # Handle sampling/createMessage request from server (MCP 2025-11-25)
924
936
  # @param request_id [String, Integer] the JSON-RPC request ID
925
937
  # @param params [Hash] the sampling parameters
926
938
  # @return [void]
@@ -956,7 +968,7 @@ module MCPClient
956
968
  @logger.error("Error sending roots/list response: #{e.message}")
957
969
  end
958
970
 
959
- # Send sampling response back to server via HTTP POST (MCP 2025-06-18)
971
+ # Send sampling response back to server via HTTP POST (MCP 2025-11-25)
960
972
  # @param request_id [String, Integer] the JSON-RPC request ID
961
973
  # @param result [Hash] the sampling result (role, content, model, stopReason)
962
974
  # @return [void]
@@ -1040,6 +1052,7 @@ module MCPClient
1040
1052
  resp = conn.post(@endpoint) do |req|
1041
1053
  @headers.each { |k, v| req.headers[k] = v }
1042
1054
  req.headers['Mcp-Session-Id'] = @session_id if @session_id
1055
+ req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
1043
1056
  req.body = json_body
1044
1057
  end
1045
1058
 
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ # Represents an MCP Task for long-running operations with progress tracking
5
+ # Tasks follow the MCP 2025-11-25 specification for structured task management
6
+ #
7
+ # Task states: pending, running, completed, failed, cancelled
8
+ class Task
9
+ # Valid task states
10
+ VALID_STATES = %w[pending running completed failed cancelled].freeze
11
+
12
+ attr_reader :id, :state, :progress_token, :progress, :total, :message, :result, :server
13
+
14
+ # Create a new Task
15
+ # @param id [String] unique task identifier
16
+ # @param state [String] task state (pending, running, completed, failed, cancelled)
17
+ # @param progress_token [String, nil] optional token for tracking progress
18
+ # @param progress [Integer, nil] current progress value
19
+ # @param total [Integer, nil] total progress value
20
+ # @param message [String, nil] human-readable status message
21
+ # @param result [Object, nil] task result (when completed)
22
+ # @param server [MCPClient::ServerBase, nil] the server this task belongs to
23
+ def initialize(id:, state: 'pending', progress_token: nil, progress: nil, total: nil,
24
+ message: nil, result: nil, server: nil)
25
+ validate_state!(state)
26
+ @id = id
27
+ @state = state
28
+ @progress_token = progress_token
29
+ @progress = progress
30
+ @total = total
31
+ @message = message
32
+ @result = result
33
+ @server = server
34
+ end
35
+
36
+ # Create a Task from a JSON hash
37
+ # @param json [Hash] the JSON hash with task fields
38
+ # @param server [MCPClient::ServerBase, nil] optional server reference
39
+ # @return [Task]
40
+ def self.from_json(json, server: nil)
41
+ new(
42
+ id: json['id'] || json[:id],
43
+ state: json['state'] || json[:state] || 'pending',
44
+ progress_token: json['progressToken'] || json[:progressToken] || json[:progress_token],
45
+ progress: json['progress'] || json[:progress],
46
+ total: json['total'] || json[:total],
47
+ message: json['message'] || json[:message],
48
+ result: json.key?('result') ? json['result'] : json[:result],
49
+ server: server
50
+ )
51
+ end
52
+
53
+ # Convert to JSON-serializable hash
54
+ # @return [Hash]
55
+ def to_h
56
+ result = { 'id' => @id, 'state' => @state }
57
+ result['progressToken'] = @progress_token if @progress_token
58
+ result['progress'] = @progress if @progress
59
+ result['total'] = @total if @total
60
+ result['message'] = @message if @message
61
+ result['result'] = @result unless @result.nil?
62
+ result
63
+ end
64
+
65
+ # Convert to JSON string
66
+ # @return [String]
67
+ def to_json(*)
68
+ to_h.to_json(*)
69
+ end
70
+
71
+ # Check if task is in a terminal state
72
+ # @return [Boolean]
73
+ def terminal?
74
+ %w[completed failed cancelled].include?(@state)
75
+ end
76
+
77
+ # Check if task is still active (pending or running)
78
+ # @return [Boolean]
79
+ def active?
80
+ %w[pending running].include?(@state)
81
+ end
82
+
83
+ # Calculate progress percentage
84
+ # @return [Float, nil] percentage (0.0-100.0) or nil if progress info unavailable
85
+ def progress_percentage
86
+ return nil unless @progress && @total&.positive?
87
+
88
+ (@progress.to_f / @total * 100).round(2)
89
+ end
90
+
91
+ # Check equality
92
+ def ==(other)
93
+ return false unless other.is_a?(Task)
94
+
95
+ id == other.id && state == other.state
96
+ end
97
+
98
+ alias eql? ==
99
+
100
+ def hash
101
+ [id, state].hash
102
+ end
103
+
104
+ # String representation
105
+ def to_s
106
+ parts = ["Task[#{@id}]: #{@state}"]
107
+ parts << "(#{@progress}/#{@total})" if @progress && @total
108
+ parts << "- #{@message}" if @message
109
+ parts.join(' ')
110
+ end
111
+
112
+ def inspect
113
+ "#<MCPClient::Task id=#{@id.inspect} state=#{@state.inspect}>"
114
+ end
115
+
116
+ private
117
+
118
+ # Validate task state
119
+ # @param state [String] the state to validate
120
+ # @raise [ArgumentError] if the state is not valid
121
+ def validate_state!(state)
122
+ return if VALID_STATES.include?(state)
123
+
124
+ raise ArgumentError, "Invalid task state: #{state.inspect}. Must be one of: #{VALID_STATES.join(', ')}"
125
+ end
126
+ end
127
+ end
@@ -5,6 +5,8 @@ module MCPClient
5
5
  class Tool
6
6
  # @!attribute [r] name
7
7
  # @return [String] the name of the tool
8
+ # @!attribute [r] title
9
+ # @return [String, nil] optional human-readable name of the tool for display purposes
8
10
  # @!attribute [r] description
9
11
  # @return [String] the description of the tool
10
12
  # @!attribute [r] schema
@@ -15,17 +17,19 @@ module MCPClient
15
17
  # @return [Hash, nil] optional annotations describing tool behavior (e.g., readOnly, destructive)
16
18
  # @!attribute [r] server
17
19
  # @return [MCPClient::ServerBase, nil] the server this tool belongs to
18
- attr_reader :name, :description, :schema, :output_schema, :annotations, :server
20
+ attr_reader :name, :title, :description, :schema, :output_schema, :annotations, :server
19
21
 
20
22
  # Initialize a new Tool
21
23
  # @param name [String] the name of the tool
22
24
  # @param description [String] the description of the tool
23
25
  # @param schema [Hash] the JSON schema for the tool inputs
26
+ # @param title [String, nil] optional human-readable name of the tool for display purposes
24
27
  # @param output_schema [Hash, nil] optional JSON schema for structured tool outputs (MCP 2025-06-18)
25
28
  # @param annotations [Hash, nil] optional annotations describing tool behavior
26
29
  # @param server [MCPClient::ServerBase, nil] the server this tool belongs to
27
- def initialize(name:, description:, schema:, output_schema: nil, annotations: nil, server: nil)
30
+ def initialize(name:, description:, schema:, title: nil, output_schema: nil, annotations: nil, server: nil)
28
31
  @name = name
32
+ @title = title
29
33
  @description = description
30
34
  @schema = schema
31
35
  @output_schema = output_schema
@@ -43,10 +47,12 @@ module MCPClient
43
47
  schema = data['inputSchema'] || data[:inputSchema] || data['schema'] || data[:schema]
44
48
  output_schema = data['outputSchema'] || data[:outputSchema]
45
49
  annotations = data['annotations'] || data[:annotations]
50
+ title = data['title'] || data[:title]
46
51
  new(
47
52
  name: data['name'] || data[:name],
48
53
  description: data['description'] || data[:description],
49
54
  schema: schema,
55
+ title: title,
50
56
  output_schema: output_schema,
51
57
  annotations: annotations,
52
58
  server: server
@@ -67,12 +73,12 @@ module MCPClient
67
73
  end
68
74
 
69
75
  # Convert tool to Anthropic Claude tool specification format
70
- # @return [Hash] Anthropic Claude tool specification
76
+ # @return [Hash] Anthropic Claude tool specification with cleaned schema
71
77
  def to_anthropic_tool
72
78
  {
73
79
  name: @name,
74
80
  description: @description,
75
- input_schema: @schema
81
+ input_schema: cleaned_schema(@schema)
76
82
  }
77
83
  end
78
84
 
@@ -86,22 +92,62 @@ module MCPClient
86
92
  }
87
93
  end
88
94
 
89
- # Check if the tool is marked as read-only
95
+ # Check if the tool is marked as read-only (legacy annotation field)
90
96
  # @return [Boolean] true if the tool is read-only
97
+ # @see #read_only_hint? for MCP 2025-11-25 annotation
91
98
  def read_only?
92
- @annotations && @annotations['readOnly'] == true
99
+ !!(@annotations && @annotations['readOnly'] == true)
93
100
  end
94
101
 
95
- # Check if the tool is marked as destructive
102
+ # Check if the tool is marked as destructive (legacy annotation field)
96
103
  # @return [Boolean] true if the tool is destructive
104
+ # @see #destructive_hint? for MCP 2025-11-25 annotation
97
105
  def destructive?
98
- @annotations && @annotations['destructive'] == true
106
+ !!(@annotations && @annotations['destructive'] == true)
99
107
  end
100
108
 
101
109
  # Check if the tool requires confirmation before execution
102
110
  # @return [Boolean] true if the tool requires confirmation
103
111
  def requires_confirmation?
104
- @annotations && @annotations['requiresConfirmation'] == true
112
+ !!(@annotations && @annotations['requiresConfirmation'] == true)
113
+ end
114
+
115
+ # Check the readOnlyHint annotation (MCP 2025-11-25)
116
+ # When true, the tool does not modify its environment.
117
+ # @return [Boolean] defaults to true when not specified
118
+ def read_only_hint?
119
+ return true unless @annotations
120
+
121
+ fetch_annotation_hint('readOnlyHint', :readOnlyHint, true)
122
+ end
123
+
124
+ # Check the destructiveHint annotation (MCP 2025-11-25)
125
+ # When true, the tool may perform destructive updates.
126
+ # Only meaningful when readOnlyHint is false.
127
+ # @return [Boolean] defaults to false when not specified
128
+ def destructive_hint?
129
+ return false unless @annotations
130
+
131
+ fetch_annotation_hint('destructiveHint', :destructiveHint, false)
132
+ end
133
+
134
+ # Check the idempotentHint annotation (MCP 2025-11-25)
135
+ # When true, calling the tool repeatedly with the same arguments has no additional effect.
136
+ # Only meaningful when readOnlyHint is false.
137
+ # @return [Boolean] defaults to false when not specified
138
+ def idempotent_hint?
139
+ return false unless @annotations
140
+
141
+ fetch_annotation_hint('idempotentHint', :idempotentHint, false)
142
+ end
143
+
144
+ # Check the openWorldHint annotation (MCP 2025-11-25)
145
+ # When true, the tool may interact with the "open world" (external entities).
146
+ # @return [Boolean] defaults to true when not specified
147
+ def open_world_hint?
148
+ return true unless @annotations
149
+
150
+ fetch_annotation_hint('openWorldHint', :openWorldHint, true)
105
151
  end
106
152
 
107
153
  # Check if the tool supports structured outputs (MCP 2025-06-18)
@@ -112,6 +158,24 @@ module MCPClient
112
158
 
113
159
  private
114
160
 
161
+ # Fetch a boolean annotation hint, checking both string and symbol keys.
162
+ # Uses Hash#key? to correctly handle false values.
163
+ # @param str_key [String] the string key to check
164
+ # @param sym_key [Symbol] the symbol key to check
165
+ # @param default [Boolean] the default value when the key is not present
166
+ # @return [Boolean] the annotation value, or the default
167
+ def fetch_annotation_hint(str_key, sym_key, default)
168
+ return default unless @annotations.is_a?(Hash)
169
+
170
+ if @annotations.key?(str_key)
171
+ @annotations[str_key]
172
+ elsif @annotations.key?(sym_key)
173
+ @annotations[sym_key]
174
+ else
175
+ default
176
+ end
177
+ end
178
+
115
179
  # Recursively remove "$schema" keys that are not accepted by Vertex AI
116
180
  # @param obj [Object] schema element (Hash/Array/other)
117
181
  # @return [Object] cleaned schema without "$schema" keys
@@ -2,8 +2,8 @@
2
2
 
3
3
  module MCPClient
4
4
  # Current version of the MCP client gem
5
- VERSION = '0.9.1'
5
+ VERSION = '1.0.1'
6
6
 
7
7
  # MCP protocol version (date-based) - unified across all transports
8
- PROTOCOL_VERSION = '2025-06-18'
8
+ PROTOCOL_VERSION = '2025-11-25'
9
9
  end