actionmcp 0.19.1 → 0.22.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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/action_mcp/messages_controller.rb +2 -2
  3. data/app/models/action_mcp/session/message.rb +12 -1
  4. data/app/models/action_mcp/session.rb +8 -4
  5. data/lib/action_mcp/base_response.rb +86 -0
  6. data/lib/action_mcp/capability.rb +2 -3
  7. data/lib/action_mcp/client/base.rb +222 -0
  8. data/lib/action_mcp/client/blueprint.rb +227 -0
  9. data/lib/action_mcp/client/catalog.rb +226 -0
  10. data/lib/action_mcp/client/json_rpc_handler.rb +109 -0
  11. data/lib/action_mcp/client/logging.rb +20 -0
  12. data/lib/action_mcp/{transport → client}/messaging.rb +1 -1
  13. data/lib/action_mcp/client/prompt_book.rb +183 -0
  14. data/lib/action_mcp/client/prompts.rb +33 -0
  15. data/lib/action_mcp/client/resources.rb +70 -0
  16. data/lib/action_mcp/client/roots.rb +13 -0
  17. data/lib/action_mcp/client/server.rb +60 -0
  18. data/lib/action_mcp/{transport → client}/sse_client.rb +70 -111
  19. data/lib/action_mcp/{transport → client}/stdio_client.rb +38 -38
  20. data/lib/action_mcp/client/toolbox.rb +236 -0
  21. data/lib/action_mcp/client/tools.rb +33 -0
  22. data/lib/action_mcp/client.rb +20 -231
  23. data/lib/action_mcp/engine.rb +1 -3
  24. data/lib/action_mcp/instrumentation/controller_runtime.rb +1 -1
  25. data/lib/action_mcp/instrumentation/instrumentation.rb +2 -0
  26. data/lib/action_mcp/instrumentation/resource_instrumentation.rb +1 -0
  27. data/lib/action_mcp/json_rpc_handler_base.rb +106 -0
  28. data/lib/action_mcp/log_subscriber.rb +2 -0
  29. data/lib/action_mcp/logging.rb +1 -1
  30. data/lib/action_mcp/prompt.rb +4 -3
  31. data/lib/action_mcp/prompt_response.rb +14 -58
  32. data/lib/action_mcp/{transport → server}/capabilities.rb +2 -2
  33. data/lib/action_mcp/server/json_rpc_handler.rb +121 -0
  34. data/lib/action_mcp/server/messaging.rb +28 -0
  35. data/lib/action_mcp/{transport → server}/notifications.rb +1 -1
  36. data/lib/action_mcp/{transport → server}/prompts.rb +1 -1
  37. data/lib/action_mcp/{transport → server}/resources.rb +1 -18
  38. data/lib/action_mcp/{transport → server}/roots.rb +1 -1
  39. data/lib/action_mcp/{transport → server}/sampling.rb +1 -1
  40. data/lib/action_mcp/server/sampling_request.rb +115 -0
  41. data/lib/action_mcp/{transport → server}/tools.rb +1 -1
  42. data/lib/action_mcp/server/transport_handler.rb +41 -0
  43. data/lib/action_mcp/tool_response.rb +14 -59
  44. data/lib/action_mcp/uri_ambiguity_checker.rb +6 -10
  45. data/lib/action_mcp/version.rb +1 -1
  46. data/lib/action_mcp.rb +2 -1
  47. metadata +30 -33
  48. data/lib/action_mcp/base_json_rpc_handler.rb +0 -97
  49. data/lib/action_mcp/client_json_rpc_handler.rb +0 -69
  50. data/lib/action_mcp/json_rpc_handler.rb +0 -229
  51. data/lib/action_mcp/sampling_request.rb +0 -113
  52. data/lib/action_mcp/server_json_rpc_handler.rb +0 -90
  53. data/lib/action_mcp/transport/transport_base.rb +0 -126
  54. data/lib/action_mcp/transport_handler.rb +0 -39
@@ -0,0 +1,60 @@
1
+ module ActionMCP
2
+ module Client
3
+ class Server
4
+ attr_reader :name, :version
5
+
6
+ attr_reader :server_info, :capabilities
7
+
8
+ def initialize(data)
9
+ # Store protocol version if needed for later use
10
+ @protocol_version = data["protocolVersion"]
11
+
12
+ # Extract server information
13
+ @server_info = data["serverInfo"] || {}
14
+ @name = server_info["name"]
15
+ @version = server_info["version"]
16
+
17
+ # Store capabilities for dynamic checking
18
+ @capabilities = data["capabilities"] || {}
19
+ end
20
+
21
+ # Check if 'tools' capability is present
22
+ def tools?
23
+ @capabilities.key?("tools")
24
+ end
25
+
26
+ # Check if 'prompts' capability is present
27
+ def prompts?
28
+ @capabilities.key?("prompts")
29
+ end
30
+
31
+ # Check if tools have a dynamic state based on listChanged flag
32
+ def dynamic_tools?
33
+ tool_cap = @capabilities["tools"] || {}
34
+ tool_cap["listChanged"] == true
35
+ end
36
+
37
+ # Check if logging capability exists
38
+ def logging?
39
+ @capabilities.key?("logging")
40
+ end
41
+
42
+ # Check if resources capability exists
43
+ def resources?
44
+ @capabilities.key?("resources")
45
+ end
46
+
47
+ # Check if resources have a dynamic state based on listChanged flag
48
+ def dynamic_resources?
49
+ resources_cap = @capabilities["resources"] || {}
50
+ resources_cap["listChanged"] == true
51
+ end
52
+
53
+ def inspect
54
+ "#<#{self.class.name} name: #{name}, version: #{version} with resources: #{resources?}, tools: #{tools?}, prompts: #{prompts?}, logging: #{logging?}>"
55
+ end
56
+
57
+ alias to_s inspect
58
+ end
59
+ end
60
+ end
@@ -4,34 +4,37 @@ require "faraday"
4
4
  require "uri"
5
5
 
6
6
  module ActionMCP
7
- module Transport
8
- class SSEClient < TransportBase
9
- TIMEOUT = 10 # Increased from 1 second
10
- ENDPOINT_TIMEOUT = 5 # Seconds
11
-
7
+ module Client
8
+ # MCP client using Server-Sent Events (SSE) transport
9
+ class SSEClient < Base
12
10
  # Define a custom error class for connection issues
13
11
  class ConnectionError < StandardError; end
14
12
 
15
- def initialize(url, **options)
16
- super(**options)
13
+ SSE_TIMEOUT = 10
14
+ ENDPOINT_TIMEOUT = 5 # Seconds
15
+
16
+ attr_reader :base_url, :sse_path, :post_url, :session
17
+
18
+ def initialize(url, connect: true, logger: ActionMCP.logger, **_options)
19
+ super(logger: logger)
20
+ @type = :sse
17
21
  setup_connection(url)
18
- @buffer = ""
22
+ @buffer = +""
19
23
  @stop_requested = false
20
24
  @endpoint_received = false
21
25
  @endpoint_mutex = Mutex.new
22
26
  @endpoint_condition = ConditionVariable.new
23
-
24
- # Add connection state management
25
27
  @connection_mutex = Mutex.new
26
28
  @connection_condition = ConditionVariable.new
27
- @connection_error = nil
28
- @connected = false
29
+
30
+ self.connect if connect
29
31
  end
30
32
 
31
- def start(initialize_request_id)
32
- log_info("Connecting to #{@base_url}#{@sse_path}...")
33
+ protected
34
+
35
+ def start_transport
36
+ log_debug("Connecting to #{@base_url}#{@sse_path}...")
33
37
  @stop_requested = false
34
- @initialize_request_id = initialize_request_id
35
38
 
36
39
  # Reset connection state before starting
37
40
  @connection_mutex.synchronize do
@@ -42,10 +45,49 @@ module ActionMCP
42
45
  # Start connection thread
43
46
  @sse_thread = Thread.new { listen_sse }
44
47
 
45
- # Wait for endpoint instead of connection completion
48
+ # Wait for endpoint
46
49
  wait_for_endpoint
47
50
  end
48
51
 
52
+ def stop_transport
53
+ log_debug("Stopping SSE connection...")
54
+ @stop_requested = true
55
+ cleanup_sse_thread
56
+ end
57
+
58
+ def send_message(json_rpc)
59
+ response = @conn.post(post_url,
60
+ json_rpc,
61
+ { "Content-Type" => "application/json" })
62
+ response.success?
63
+ end
64
+
65
+ def ready?
66
+ endpoint_ready?
67
+ end
68
+
69
+ private
70
+
71
+ def setup_connection(url)
72
+ uri = URI.parse(url)
73
+ @base_url = "#{uri.scheme}://#{uri.host}:#{uri.port}"
74
+ @sse_path = uri.path
75
+
76
+ @conn = Faraday.new(url: @base_url) do |f|
77
+ f.headers["User-Agent"] = user_agent
78
+ f.options.timeout = nil # No read timeout
79
+ f.options.open_timeout = SSE_TIMEOUT # Connection timeout
80
+
81
+ # Use Net::HTTP adapter explicitly as it works well with streaming
82
+ f.adapter :net_http do |http|
83
+ http.read_timeout = nil # No read timeout at adapter level too
84
+ http.open_timeout = SSE_TIMEOUT # Connection timeout
85
+ end
86
+ end
87
+
88
+ @post_url = nil
89
+ end
90
+
49
91
  def wait_for_endpoint
50
92
  success = false
51
93
  error = nil
@@ -80,62 +122,12 @@ module ActionMCP
80
122
  success
81
123
  end
82
124
 
83
- def send_message(json_rpc)
84
- # Wait for endpoint if not yet received
85
- unless endpoint_ready?
86
- log_info("Waiting for endpoint before sending message...")
87
- wait_for_endpoint
88
- end
89
-
90
- validate_post_endpoint
91
- log_debug("\e[34m--> #{json_rpc}\e[0m")
92
- send_http_request(json_rpc)
93
- end
94
-
95
- def stop
96
- log_info("Stopping SSE connection...")
97
- @stop_requested = true
98
- cleanup_sse_thread
99
- end
100
-
101
- def ready?
102
- endpoint_ready?
103
- end
104
-
105
- private
106
-
107
- def setup_connection(url)
108
- uri = URI.parse(url)
109
- @base_url = "#{uri.scheme}://#{uri.host}:#{uri.port}"
110
- @sse_path = uri.path
111
-
112
- @conn = Faraday.new(url: @base_url) do |f|
113
- f.headers["User-Agent"] = user_agent
114
-
115
- f.options.timeout = nil # No read timeout
116
- f.options.open_timeout = 10 # Connection timeout
117
-
118
- # Use Net::HTTP adapter explicitly as it works well with streaming
119
- f.adapter :net_http do |http|
120
- # Configure the adapter directly
121
- http.read_timeout = nil # No read timeout at adapter level too
122
- http.open_timeout = 10 # Connection timeout
123
- end
124
-
125
- # Add logger middleware
126
- # f.response :logger, @logger, headers: true, bodies: true
127
- end
128
-
129
- @post_url = nil
130
- end
131
-
132
125
  def endpoint_ready?
133
126
  @endpoint_mutex.synchronize { @endpoint_received }
134
127
  end
135
128
 
136
- # The listen_sse method should NOT mark connection as successful at the end
137
129
  def listen_sse
138
- log_info("Starting SSE listener...")
130
+ log_debug("Starting SSE listener...")
139
131
 
140
132
  begin
141
133
  @conn.get(@sse_path) do |req|
@@ -151,8 +143,6 @@ module ActionMCP
151
143
  # as the SSE connection stays open
152
144
  rescue Faraday::ConnectionFailed => e
153
145
  handle_connection_error(format_connection_error(e))
154
- rescue StandardError => e
155
- handle_connection_error("Unexpected error: #{e.message}")
156
146
  end
157
147
  end
158
148
 
@@ -164,26 +154,18 @@ module ActionMCP
164
154
  end
165
155
  end
166
156
 
167
- def handle_connection_error(message)
168
- log_error("SSE connection failed: #{message}")
169
-
170
- # Set error and notify waiting threads
157
+ def connection_error=(message)
171
158
  @connection_mutex.synchronize do
172
159
  @connection_error = message
173
160
  @connection_condition.broadcast
174
161
  end
175
-
176
- @on_error&.call(StandardError.new(message))
177
162
  end
178
163
 
179
- # Send the initialized notification to the server
180
- def send_initialized_notification
181
- notification = JsonRpc::Notification.new(
182
- method: "notifications/initialized"
183
- )
184
-
185
- logger.info("Sent initialized notification to server")
186
- send_message(notification.to_json)
164
+ def handle_connection_error(message)
165
+ log_error("SSE connection failed: #{message}")
166
+ self.connection_error = message
167
+ @connected = false
168
+ @error_callback&.call(StandardError.new(message))
187
169
  end
188
170
 
189
171
  def handle_sse_data(chunk, _overall_bytes)
@@ -195,9 +177,9 @@ module ActionMCP
195
177
  @buffer << chunk
196
178
  # If the buffer does not contain a newline but appears to be a complete JSON object,
197
179
  # flush it as a complete event.
198
- if @buffer.strip.start_with?("{") && @buffer.strip.end_with?("}")
180
+ if @buffer.strip.match?(/^\{.*\}$/)
199
181
  (@current_event ||= []) << @buffer.strip
200
- @buffer = ""
182
+ @buffer = +""
201
183
  return handle_complete_event
202
184
  end
203
185
  process_buffer while @buffer.include?("\n")
@@ -227,7 +209,7 @@ module ActionMCP
227
209
  end
228
210
 
229
211
  def parse_event(lines)
230
- event_data = { type: "message", data: "" }
212
+ event_data = { type: "message", data: +"" }
231
213
  has_data_prefix = false
232
214
 
233
215
  lines.each do |line|
@@ -248,15 +230,13 @@ module ActionMCP
248
230
  case event_data[:type]
249
231
  when "endpoint" then set_post_endpoint(event_data[:data])
250
232
  when "message" then handle_raw_message(event_data[:data])
251
- when "ping" then log_debug("Received ping")
252
233
  else log_error("Unknown event type: #{event_data[:type]}")
253
234
  end
254
235
  end
255
236
 
256
- # Modify set_post_endpoint to mark connection as ready
257
237
  def set_post_endpoint(endpoint_path)
258
238
  @post_url = build_post_url(endpoint_path)
259
- log_info("Received POST endpoint: #{@post_url}")
239
+ log_debug("Received POST endpoint: #{post_url}")
260
240
 
261
241
  # Signal that we have received the endpoint
262
242
  @endpoint_mutex.synchronize do
@@ -274,31 +254,10 @@ module ActionMCP
274
254
  "#{@base_url}#{endpoint_path}"
275
255
  end
276
256
 
277
- def validate_post_endpoint
278
- raise "MCP endpoint not set (no 'endpoint' event received)" unless @post_url
279
- end
280
-
281
- def send_http_request(json_rpc)
282
- response = @conn.post(@post_url,
283
- json_rpc,
284
- { "Content-Type" => "application/json" })
285
- handle_http_response(response)
286
- end
287
-
288
- def handle_http_response(response)
289
- return if response.success?
290
-
291
- log_error("HTTP POST failed: #{response.status} - #{response.body}")
292
- end
293
-
294
257
  def cleanup_sse_thread
295
258
  return unless @sse_thread
296
259
 
297
- @sse_thread.join(TIMEOUT) || @sse_thread.kill
298
- end
299
-
300
- def user_agent
301
- "ActionMCP-sse-client"
260
+ @sse_thread.join(SSE_TIMEOUT) || @sse_thread.kill
302
261
  end
303
262
  end
304
263
  end
@@ -1,65 +1,55 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
- module ActionMCP
5
- module Transport
6
- class StdioClient < TransportBase
7
- attr_reader :received_server_message
8
4
 
9
- def initialize(command, **options)
10
- super(**options)
11
- @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(command)
5
+ module ActionMCP
6
+ module Client
7
+ # MCP client using Standard I/O (STDIO) transport, Not tested for now
8
+ class StdioClient < Base
9
+ def initialize(command, logger: ActionMCP.logger, **_options)
10
+ super(logger: logger)
11
+ @type = :stdio
12
+ @command = command
12
13
  @threads_started = false
13
14
  @received_server_message = false
14
15
  @capabilities_sent = false
15
16
  end
16
17
 
17
- def start
18
+ protected
19
+
20
+ def start_transport
21
+ setup_stdio_process
18
22
  start_output_threads
19
23
 
20
24
  # Just log that connection is established but don't send capabilities yet
21
25
  if @threads_started && @wait_thr.alive?
22
- log_info("STDIO connection established")
26
+ log_debug("STDIO connection established")
27
+ true
23
28
  else
24
- log_error("Failed to start STDIO threads or process is not alive")
29
+ log_debug("Failed to start STDIO threads or process is not alive")
30
+ false
25
31
  end
26
32
  end
27
33
 
34
+ def stop_transport
35
+ cleanup_resources
36
+ end
37
+
28
38
  def send_message(json)
29
39
  log_debug("\e[34m--> #{json}\e[0m")
30
40
  @stdin.puts("#{json}\n\n")
31
41
  end
32
42
 
33
- def stop
34
- cleanup_resources
35
- end
36
-
37
43
  def ready?
38
- true
39
- end
40
-
41
- # Check if we've received any message from the server
42
- def received_server_message?
43
44
  @received_server_message
44
45
  end
45
46
 
46
- # Mark the client as ready and send initial capabilities if not already sent
47
- def mark_ready_and_send_capabilities
48
- return if @received_server_message
49
-
50
- @received_server_message = true
51
- log_info("Received first server message")
52
-
53
- # Send initial capabilities if not already sent
54
- return if @capabilities_sent
47
+ private
55
48
 
56
- log_info("Server is ready, sending initial capabilities...")
57
- send_initial_capabilities
58
- @capabilities_sent = true
49
+ def setup_stdio_process
50
+ @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(@command)
59
51
  end
60
52
 
61
- private
62
-
63
53
  def start_output_threads
64
54
  @stdout_thread = Thread.new do
65
55
  @stdout.each_line do |line|
@@ -75,7 +65,6 @@ module ActionMCP
75
65
  @stderr_thread = Thread.new do
76
66
  @stderr.each_line do |line|
77
67
  line = line.chomp
78
- log_info(line)
79
68
 
80
69
  # Check stderr for server messages
81
70
  mark_ready_and_send_capabilities if line.include?("MCP Server") || line.include?("running on stdio")
@@ -85,6 +74,21 @@ module ActionMCP
85
74
  @threads_started = true
86
75
  end
87
76
 
77
+ # Mark the client as ready and send initial capabilities if not already sent
78
+ def mark_ready_and_send_capabilities
79
+ return if @received_server_message
80
+
81
+ @received_server_message = true
82
+ log_debug("Received first server message")
83
+
84
+ # Send initial capabilities if not already sent
85
+ return if @capabilities_sent
86
+
87
+ log_debug("Server is ready, sending initial capabilities...")
88
+ send_initial_capabilities
89
+ @capabilities_sent = true
90
+ end
91
+
88
92
  def cleanup_resources
89
93
  @stdin.close
90
94
  wait_for_server_exit
@@ -106,10 +110,6 @@ module ActionMCP
106
110
  @stdout_thread&.kill
107
111
  @stderr_thread&.kill
108
112
  end
109
-
110
- def user_agent
111
- "ActionMCP-stdio-client"
112
- end
113
113
  end
114
114
  end
115
115
  end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Client
5
+ # Toolbox
6
+ #
7
+ # A collection that manages and provides access to tools from the server.
8
+ # This class stores tool definitions along with their input schemas and
9
+ # provides methods for retrieving, filtering, and accessing tools.
10
+ #
11
+ # Example usage:
12
+ # tools_data = client.list_tools # Returns array of tool definitions
13
+ # toolbox = Toolbox.new(tools_data)
14
+ #
15
+ # # Access a specific tool by name
16
+ # weather_tool = toolbox.find("weather_forecast")
17
+ #
18
+ # # Get all tools matching a criteria
19
+ # calculation_tools = toolbox.filter { |t| t.name.include?("calculate") }
20
+ #
21
+ class Toolbox
22
+ attr_reader :client
23
+
24
+ # Initialize a new Toolbox with tool definitions
25
+ #
26
+ # @param tools [Array<Hash>] Array of tool definition hashes, each containing
27
+ # name, description, and inputSchema keys
28
+ def initialize(tools, client)
29
+ self.tools = tools
30
+ @client = client
31
+ @loaded = !tools.empty?
32
+ end
33
+
34
+ # Return all tools in the collection
35
+ #
36
+ # @return [Array<Tool>] All tool objects in the collection
37
+ def all
38
+ load_tools unless @loaded
39
+ @tools
40
+ end
41
+
42
+ def all!
43
+ load_tools(force: true)
44
+ @tools
45
+ end
46
+
47
+ # Find a tool by name
48
+ #
49
+ # @param name [String] Name of the tool to find
50
+ # @return [Tool, nil] The tool with the given name, or nil if not found
51
+ def find(name)
52
+ all.find { |tool| tool.name == name }
53
+ end
54
+
55
+ # Filter tools based on a given block
56
+ #
57
+ # @yield [tool] Block that determines whether to include a tool
58
+ # @yieldparam tool [Tool] A tool from the collection
59
+ # @yieldreturn [Boolean] true to include the tool, false to exclude it
60
+ # @return [Array<Tool>] Tools that match the filter criteria
61
+ def filter(&block)
62
+ all.select(&block)
63
+ end
64
+
65
+ # Get a list of all tool names
66
+ #
67
+ # @return [Array<String>] Names of all tools in the collection
68
+ def names
69
+ all.map(&:name)
70
+ end
71
+
72
+ # Number of tools in the collection
73
+ #
74
+ # @return [Integer] The number of tools
75
+ def size
76
+ all.size
77
+ end
78
+
79
+ # Check if the collection contains a tool with the given name
80
+ #
81
+ # @param name [String] The tool name to check for
82
+ # @return [Boolean] true if a tool with the name exists
83
+ def contains?(name)
84
+ all.any? { |tool| tool.name == name }
85
+ end
86
+
87
+ # Get tools by category or type
88
+ #
89
+ # @param keyword [String] Keyword to search for in tool names and descriptions
90
+ # @return [Array<Tool>] Tools containing the keyword
91
+ def search(keyword)
92
+ @tools.select do |tool|
93
+ tool.name.include?(keyword) || tool.description.downcase.include?(keyword.downcase)
94
+ end
95
+ end
96
+
97
+ # Generate a hash representation of all tools in the collection based on provider format
98
+ #
99
+ # @param provider [Symbol] The provider format to use (:claude, :openai, or :default)
100
+ # @return [Hash] Hash containing all tools formatted for the specified provider
101
+ def to_h(provider = :default)
102
+ case provider
103
+ when :claude
104
+ # Claude format
105
+ { "tools" => @tools.map(&:to_claude_h) }
106
+ when :openai
107
+ # OpenAI format
108
+ { "tools" => @tools.map(&:to_openai_h) }
109
+ else
110
+ # Default format (same as original)
111
+ { "tools" => @tools.map(&:to_h) }
112
+ end
113
+ end
114
+
115
+ # Implements enumerable functionality for the collection
116
+ include Enumerable
117
+
118
+ # Yield each tool in the collection to the given block
119
+ #
120
+ # @yield [tool] Block to execute for each tool
121
+ # @yieldparam tool [Tool] A tool from the collection
122
+ # @return [Enumerator] If no block is given
123
+ def each(&block)
124
+ @tools.each(&block)
125
+ end
126
+
127
+ def tools=(tools)
128
+ @tools = tools.map { |tool_data| Tool.new(tool_data) }
129
+ end
130
+
131
+ private
132
+
133
+ def load_tools(force: false)
134
+ return if @loaded && !force
135
+
136
+ begin
137
+ @client.list_tools
138
+ @loaded = true
139
+ rescue StandardError => e
140
+ # Handle error appropriately
141
+ Rails.logger.error("Failed to load tools: #{e.message}")
142
+ # Still mark as loaded but with empty list?
143
+ @loaded = true unless @tools.empty?
144
+ end
145
+ end
146
+
147
+ # Internal Tool class to represent individual tools
148
+ class Tool
149
+ attr_reader :name, :description, :input_schema
150
+
151
+ # Initialize a new Tool instance
152
+ #
153
+ # @param data [Hash] Tool definition hash containing name, description, and inputSchema
154
+ def initialize(data)
155
+ @name = data["name"]
156
+ @description = data["description"]
157
+ @input_schema = data["inputSchema"] || {}
158
+ end
159
+
160
+ # Get all required properties for this tool
161
+ #
162
+ # @return [Array<String>] Array of required property names
163
+ def required_properties
164
+ @input_schema["required"] || []
165
+ end
166
+
167
+ # Get all properties for this tool
168
+ #
169
+ # @return [Hash] Hash of property definitions
170
+ def properties
171
+ @input_schema.dig("properties") || {}
172
+ end
173
+
174
+ # Check if the tool requires a specific property
175
+ #
176
+ # @param name [String] Name of the property to check
177
+ # @return [Boolean] true if the property is required
178
+ def requires?(name)
179
+ required_properties.include?(name)
180
+ end
181
+
182
+ # Check if the tool has a specific property
183
+ #
184
+ # @param name [String] Name of the property to check
185
+ # @return [Boolean] true if the property exists
186
+ def has_property?(name)
187
+ properties.key?(name)
188
+ end
189
+
190
+ # Get property details by name
191
+ #
192
+ # @param name [String] Name of the property
193
+ # @return [Hash, nil] Property details or nil if not found
194
+ def property(name)
195
+ properties[name]
196
+ end
197
+
198
+ # Generate a hash representation of the tool (default format)
199
+ #
200
+ # @return [Hash] Hash containing tool details
201
+ def to_h
202
+ {
203
+ "name" => @name,
204
+ "description" => @description,
205
+ "inputSchema" => @input_schema
206
+ }
207
+ end
208
+
209
+ # Generate a hash representation of the tool in Claude format
210
+ #
211
+ # @return [Hash] Hash containing tool details formatted for Claude
212
+ def to_claude_h
213
+ {
214
+ "name" => @name,
215
+ "description" => @description,
216
+ "input_schema" => @input_schema.transform_keys { |k| k == "inputSchema" ? "input_schema" : k }
217
+ }
218
+ end
219
+
220
+ # Generate a hash representation of the tool in OpenAI format
221
+ #
222
+ # @return [Hash] Hash containing tool details formatted for OpenAI
223
+ def to_openai_h
224
+ {
225
+ "type" => "function",
226
+ "function" => {
227
+ "name" => @name,
228
+ "description" => @description,
229
+ "parameters" => @input_schema
230
+ }
231
+ }
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end