actionmcp 0.2.0 → 0.2.3
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 +4 -4
- data/README.md +133 -30
- data/Rakefile +0 -2
- data/exe/actionmcp_cli +221 -0
- data/lib/action_mcp/capability.rb +52 -0
- data/lib/action_mcp/client.rb +243 -1
- data/lib/action_mcp/configuration.rb +50 -1
- data/lib/action_mcp/content/audio.rb +9 -0
- data/lib/action_mcp/content/image.rb +9 -0
- data/lib/action_mcp/content/resource.rb +13 -0
- data/lib/action_mcp/content/text.rb +7 -0
- data/lib/action_mcp/content.rb +11 -6
- data/lib/action_mcp/engine.rb +34 -0
- data/lib/action_mcp/gem_version.rb +2 -2
- data/lib/action_mcp/integer_array.rb +6 -0
- data/lib/action_mcp/json_rpc/json_rpc_error.rb +21 -0
- data/lib/action_mcp/json_rpc/notification.rb +8 -0
- data/lib/action_mcp/json_rpc/request.rb +14 -0
- data/lib/action_mcp/json_rpc/response.rb +32 -1
- data/lib/action_mcp/json_rpc.rb +1 -6
- data/lib/action_mcp/json_rpc_handler.rb +106 -0
- data/lib/action_mcp/logging.rb +19 -0
- data/lib/action_mcp/prompt.rb +30 -46
- data/lib/action_mcp/prompts_registry.rb +13 -1
- data/lib/action_mcp/registry_base.rb +47 -28
- data/lib/action_mcp/renderable.rb +26 -0
- data/lib/action_mcp/resource.rb +3 -1
- data/lib/action_mcp/server.rb +4 -1
- data/lib/action_mcp/string_array.rb +5 -0
- data/lib/action_mcp/tool.rb +16 -53
- data/lib/action_mcp/tools_registry.rb +14 -1
- data/lib/action_mcp/transport/capabilities.rb +21 -0
- data/lib/action_mcp/transport/messaging.rb +20 -0
- data/lib/action_mcp/transport/prompts.rb +19 -0
- data/lib/action_mcp/transport/sse_client.rb +309 -0
- data/lib/action_mcp/transport/stdio_client.rb +117 -0
- data/lib/action_mcp/transport/tools.rb +20 -0
- data/lib/action_mcp/transport/transport_base.rb +125 -0
- data/lib/action_mcp/transport.rb +1 -235
- data/lib/action_mcp/transport_handler.rb +54 -0
- data/lib/action_mcp/version.rb +4 -5
- data/lib/action_mcp.rb +36 -33
- data/lib/generators/action_mcp/prompt/templates/prompt.rb.erb +3 -1
- data/lib/generators/action_mcp/tool/templates/tool.rb.erb +5 -1
- data/lib/tasks/action_mcp_tasks.rake +28 -5
- metadata +62 -9
- data/exe/action_mcp_stdio +0 -0
- data/lib/action_mcp/railtie.rb +0 -27
- data/lib/action_mcp/resources_bank.rb +0 -94
data/lib/action_mcp/tool.rb
CHANGED
@@ -5,49 +5,16 @@ module ActionMCP
|
|
5
5
|
#
|
6
6
|
# Provides a DSL for specifying metadata, properties, and nested collection schemas.
|
7
7
|
# Tools are registered automatically in the ToolsRegistry unless marked as abstract.
|
8
|
-
class Tool
|
9
|
-
include ActiveModel::Model
|
10
|
-
include ActiveModel::Attributes
|
11
|
-
include Renderable
|
12
|
-
|
8
|
+
class Tool < Capability
|
13
9
|
# --------------------------------------------------------------------------
|
14
10
|
# Class Attributes for Tool Metadata and Schema
|
15
11
|
# --------------------------------------------------------------------------
|
16
|
-
|
17
|
-
|
12
|
+
# @!attribute _schema_properties
|
13
|
+
# @return [Hash] The schema properties of the tool.
|
14
|
+
# @!attribute _required_properties
|
15
|
+
# @return [Array<String>] The required properties of the tool.
|
18
16
|
class_attribute :_schema_properties, instance_accessor: false, default: {}
|
19
17
|
class_attribute :_required_properties, instance_accessor: false, default: []
|
20
|
-
class_attribute :abstract_tool, instance_accessor: false, default: false
|
21
|
-
|
22
|
-
# --------------------------------------------------------------------------
|
23
|
-
# Subclass Registration
|
24
|
-
# --------------------------------------------------------------------------
|
25
|
-
# Automatically registers non-abstract subclasses in the ToolsRegistry.
|
26
|
-
#
|
27
|
-
# @param subclass [Class] the subclass inheriting from Tool.
|
28
|
-
def self.inherited(subclass)
|
29
|
-
super
|
30
|
-
return if subclass == Tool
|
31
|
-
|
32
|
-
subclass.abstract_tool = false
|
33
|
-
return if subclass.name == "ApplicationTool"
|
34
|
-
|
35
|
-
ToolsRegistry.register(subclass.tool_name, subclass)
|
36
|
-
end
|
37
|
-
|
38
|
-
# Marks this tool as abstract so that it won’t be available for use.
|
39
|
-
# If the tool is registered in ToolsRegistry, it is unregistered.
|
40
|
-
def self.abstract!
|
41
|
-
self.abstract_tool = true
|
42
|
-
ToolsRegistry.unregister(tool_name) if ToolsRegistry.items.key?(tool_name)
|
43
|
-
end
|
44
|
-
|
45
|
-
# Returns whether this tool is abstract.
|
46
|
-
#
|
47
|
-
# @return [Boolean] true if abstract, false otherwise.
|
48
|
-
def self.abstract?
|
49
|
-
abstract_tool
|
50
|
-
end
|
51
18
|
|
52
19
|
# --------------------------------------------------------------------------
|
53
20
|
# Tool Name and Description DSL
|
@@ -58,9 +25,9 @@ module ActionMCP
|
|
58
25
|
# @return [String] The current tool name.
|
59
26
|
def self.tool_name(name = nil)
|
60
27
|
if name
|
61
|
-
self.
|
28
|
+
self._capability_name = name
|
62
29
|
else
|
63
|
-
|
30
|
+
_capability_name || default_tool_name
|
64
31
|
end
|
65
32
|
end
|
66
33
|
|
@@ -68,19 +35,11 @@ module ActionMCP
|
|
68
35
|
#
|
69
36
|
# @return [String] The default tool name.
|
70
37
|
def self.default_tool_name
|
71
|
-
name.demodulize.underscore.
|
38
|
+
name.demodulize.underscore.sub(/_tool$/, "")
|
72
39
|
end
|
73
40
|
|
74
|
-
|
75
|
-
|
76
|
-
# @param text [String, nil] Optional. The description text to set.
|
77
|
-
# @return [String] The current description.
|
78
|
-
def self.description(text = nil)
|
79
|
-
if text
|
80
|
-
self._description = text
|
81
|
-
else
|
82
|
-
_description
|
83
|
-
end
|
41
|
+
class << self
|
42
|
+
alias default_capability_name default_tool_name
|
84
43
|
end
|
85
44
|
|
86
45
|
# --------------------------------------------------------------------------
|
@@ -97,6 +56,7 @@ module ActionMCP
|
|
97
56
|
# @param required [Boolean] Whether the property is required (default: false).
|
98
57
|
# @param default [Object, nil] The default value for the property.
|
99
58
|
# @param opts [Hash] Additional options for the JSON Schema.
|
59
|
+
# @return [void]
|
100
60
|
def self.property(prop_name, type: "string", description: nil, required: false, default: nil, **opts)
|
101
61
|
# Build the JSON Schema definition.
|
102
62
|
prop_definition = { type: type }
|
@@ -127,6 +87,7 @@ module ActionMCP
|
|
127
87
|
# @param description [String, nil] Optional description for the collection.
|
128
88
|
# @param required [Boolean] Whether the collection is required (default: false).
|
129
89
|
# @param default [Array, nil] The default value for the collection.
|
90
|
+
# @return [void]
|
130
91
|
def self.collection(prop_name, type:, description: nil, required: false, default: [])
|
131
92
|
raise ArgumentError, "Type is required for a collection" if type.nil?
|
132
93
|
|
@@ -166,6 +127,7 @@ module ActionMCP
|
|
166
127
|
# Subclasses must implement this method.
|
167
128
|
#
|
168
129
|
# @raise [NotImplementedError] Always raised if not implemented in a subclass.
|
130
|
+
# @return [Array<Content>] Array of Content objects is expected as return value
|
169
131
|
def call
|
170
132
|
raise NotImplementedError, "Subclasses must implement the call method"
|
171
133
|
# Default implementation (no-op)
|
@@ -184,13 +146,14 @@ module ActionMCP
|
|
184
146
|
# @return [Symbol] The corresponding ActiveModel attribute type.
|
185
147
|
def self.map_json_type_to_active_model_type(type)
|
186
148
|
case type.to_s
|
187
|
-
when "number" then
|
149
|
+
when "number" then :float # JSON Schema "number" is a float in Ruby, the spec doesn't have an integer type yet.
|
188
150
|
when "array_number" then :integer_array
|
189
151
|
when "array_integer" then :string_array
|
190
152
|
when "array_string" then :string_array
|
191
|
-
else
|
153
|
+
else :string
|
192
154
|
end
|
193
155
|
end
|
156
|
+
|
194
157
|
private_class_method :map_json_type_to_active_model_type
|
195
158
|
end
|
196
159
|
end
|
@@ -1,11 +1,20 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module ActionMCP
|
4
|
+
# Registry for managing tools.
|
4
5
|
class ToolsRegistry < RegistryBase
|
5
6
|
class << self
|
7
|
+
# @!method tools
|
8
|
+
# Returns all registered tools.
|
9
|
+
# @return [Hash] A hash of registered tools.
|
6
10
|
alias tools items
|
7
|
-
alias available_tools enabled
|
8
11
|
|
12
|
+
# Calls a tool with the given name and arguments.
|
13
|
+
#
|
14
|
+
# @param tool_name [String] The name of the tool to call.
|
15
|
+
# @param arguments [Hash] The arguments to pass to the tool.
|
16
|
+
# @param _metadata [Hash] Optional metadata.
|
17
|
+
# @return [Hash] A hash containing the tool's response.
|
9
18
|
def tool_call(tool_name, arguments, _metadata = {})
|
10
19
|
tool = find(tool_name)
|
11
20
|
tool = tool.new(arguments)
|
@@ -19,6 +28,10 @@ module ActionMCP
|
|
19
28
|
}
|
20
29
|
end
|
21
30
|
end
|
31
|
+
|
32
|
+
def item_klass
|
33
|
+
Tool
|
34
|
+
end
|
22
35
|
end
|
23
36
|
end
|
24
37
|
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module ActionMCP
|
2
|
+
module Transport
|
3
|
+
module Capabilities
|
4
|
+
def send_capabilities(request_id, params = {})
|
5
|
+
@protocol_version = params["protocolVersion"]
|
6
|
+
@client_info = params["clientInfo"]
|
7
|
+
@client_capabilities = params["capabilities"]
|
8
|
+
capabilities = ActionMCP.configuration.capabilities
|
9
|
+
|
10
|
+
payload = {
|
11
|
+
protocolVersion: PROTOCOL_VERSION,
|
12
|
+
serverInfo: {
|
13
|
+
name: ActionMCP.configuration.name,
|
14
|
+
version: ActionMCP.configuration.version
|
15
|
+
}
|
16
|
+
}.merge(capabilities)
|
17
|
+
send_jsonrpc_response(request_id, result: payload)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module ActionMCP
|
2
|
+
module Transport
|
3
|
+
module Messaging
|
4
|
+
def send_jsonrpc_request(method, params: nil, id: SecureRandom.uuid_v7)
|
5
|
+
request = JsonRpc::Request.new(id: id, method: method, params: params)
|
6
|
+
write_message(request.to_json)
|
7
|
+
end
|
8
|
+
|
9
|
+
def send_jsonrpc_response(request_id, result: nil, error: nil)
|
10
|
+
response = JsonRpc::Response.new(id: request_id, result: result, error: error)
|
11
|
+
write_message(response.to_json)
|
12
|
+
end
|
13
|
+
|
14
|
+
def send_jsonrpc_notification(method, params = nil)
|
15
|
+
notification = JsonRpc::Notification.new(method: method, params: params)
|
16
|
+
write_message(notification.to_json)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module ActionMCP
|
2
|
+
module Transport
|
3
|
+
module Prompts
|
4
|
+
def send_prompts_list(request_id)
|
5
|
+
prompts = format_registry_items(PromptsRegistry.non_abstract)
|
6
|
+
send_jsonrpc_response(request_id, result: { prompts: prompts })
|
7
|
+
end
|
8
|
+
|
9
|
+
def send_prompts_get(request_id, prompt_name, params)
|
10
|
+
send_jsonrpc_response(request_id, result: PromptsRegistry.prompt_call(prompt_name.to_s, params))
|
11
|
+
rescue RegistryBase::NotFound
|
12
|
+
send_jsonrpc_response(request_id, error: JsonRpc::JsonRpcError.new(
|
13
|
+
:method_not_found,
|
14
|
+
message: "Prompt not found: #{prompt_name}"
|
15
|
+
).as_json)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,309 @@
|
|
1
|
+
require "faraday"
|
2
|
+
require "uri"
|
3
|
+
|
4
|
+
module ActionMCP
|
5
|
+
module Transport
|
6
|
+
class SSEClient < TransportBase
|
7
|
+
TIMEOUT = 10 # Increased from 1 second
|
8
|
+
ENDPOINT_TIMEOUT = 5 # Seconds
|
9
|
+
|
10
|
+
# Define a custom error class for connection issues
|
11
|
+
class ConnectionError < StandardError; end
|
12
|
+
|
13
|
+
def initialize(url, **options)
|
14
|
+
super(**options)
|
15
|
+
setup_connection(url)
|
16
|
+
@buffer = ""
|
17
|
+
@stop_requested = false
|
18
|
+
@endpoint_received = false
|
19
|
+
@endpoint_mutex = Mutex.new
|
20
|
+
@endpoint_condition = ConditionVariable.new
|
21
|
+
|
22
|
+
# Add connection state management
|
23
|
+
@connection_mutex = Mutex.new
|
24
|
+
@connection_condition = ConditionVariable.new
|
25
|
+
@connection_error = nil
|
26
|
+
@connected = false
|
27
|
+
end
|
28
|
+
|
29
|
+
def start(initialize_request_id)
|
30
|
+
log_info("Connecting to #{@base_url}#{@sse_path}...")
|
31
|
+
@stop_requested = false
|
32
|
+
@initialize_request_id = initialize_request_id
|
33
|
+
|
34
|
+
# Reset connection state before starting
|
35
|
+
@connection_mutex.synchronize do
|
36
|
+
@connected = false
|
37
|
+
@connection_error = nil
|
38
|
+
end
|
39
|
+
|
40
|
+
# Start connection thread
|
41
|
+
@sse_thread = Thread.new { listen_sse }
|
42
|
+
|
43
|
+
# Wait for endpoint instead of connection completion
|
44
|
+
wait_for_endpoint
|
45
|
+
end
|
46
|
+
|
47
|
+
def wait_for_endpoint
|
48
|
+
success = false
|
49
|
+
error = nil
|
50
|
+
|
51
|
+
@endpoint_mutex.synchronize do
|
52
|
+
unless @endpoint_received
|
53
|
+
# Wait with timeout for endpoint
|
54
|
+
timeout = @endpoint_condition.wait(@endpoint_mutex, ENDPOINT_TIMEOUT)
|
55
|
+
|
56
|
+
# Handle timeout
|
57
|
+
unless timeout || @endpoint_received
|
58
|
+
error = "Timeout waiting for MCP endpoint (#{ENDPOINT_TIMEOUT} seconds)"
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
success = @endpoint_received
|
63
|
+
end
|
64
|
+
|
65
|
+
if error
|
66
|
+
log_error(error)
|
67
|
+
raise ConnectionError.new(error)
|
68
|
+
end
|
69
|
+
|
70
|
+
# If we have the endpoint, consider the connection successful
|
71
|
+
if success
|
72
|
+
@connection_mutex.synchronize do
|
73
|
+
@connected = true
|
74
|
+
@connection_condition.broadcast
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
success
|
79
|
+
end
|
80
|
+
|
81
|
+
def send_message(json_rpc)
|
82
|
+
# Wait for endpoint if not yet received
|
83
|
+
unless endpoint_ready?
|
84
|
+
log_info("Waiting for endpoint before sending message...")
|
85
|
+
wait_for_endpoint
|
86
|
+
end
|
87
|
+
|
88
|
+
validate_post_endpoint
|
89
|
+
log_debug("\e[34m" + "--> #{json_rpc}" + "\e[0m")
|
90
|
+
send_http_request(json_rpc)
|
91
|
+
end
|
92
|
+
|
93
|
+
def stop
|
94
|
+
log_info("Stopping SSE connection...")
|
95
|
+
@stop_requested = true
|
96
|
+
cleanup_sse_thread
|
97
|
+
end
|
98
|
+
|
99
|
+
def ready?
|
100
|
+
endpoint_ready?
|
101
|
+
end
|
102
|
+
|
103
|
+
private
|
104
|
+
|
105
|
+
def setup_connection(url)
|
106
|
+
uri = URI.parse(url)
|
107
|
+
@base_url = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
108
|
+
@sse_path = uri.path
|
109
|
+
|
110
|
+
@conn = Faraday.new(url: @base_url) do |f|
|
111
|
+
f.headers["User-Agent"] = user_agent
|
112
|
+
|
113
|
+
f.options.timeout = nil # No read timeout
|
114
|
+
f.options.open_timeout = 10 # Connection timeout
|
115
|
+
|
116
|
+
# Use Net::HTTP adapter explicitly as it works well with streaming
|
117
|
+
f.adapter :net_http do |http|
|
118
|
+
# Configure the adapter directly
|
119
|
+
http.read_timeout = nil # No read timeout at adapter level too
|
120
|
+
http.open_timeout = 10 # Connection timeout
|
121
|
+
end
|
122
|
+
|
123
|
+
# Add logger middleware
|
124
|
+
# f.response :logger, @logger, headers: true, bodies: true
|
125
|
+
end
|
126
|
+
|
127
|
+
@post_url = nil
|
128
|
+
end
|
129
|
+
|
130
|
+
def endpoint_ready?
|
131
|
+
@endpoint_mutex.synchronize { @endpoint_received }
|
132
|
+
end
|
133
|
+
|
134
|
+
private
|
135
|
+
|
136
|
+
# The listen_sse method should NOT mark connection as successful at the end
|
137
|
+
def listen_sse
|
138
|
+
log_info("Starting SSE listener...")
|
139
|
+
|
140
|
+
begin
|
141
|
+
@conn.get(@sse_path) do |req|
|
142
|
+
req.headers["Accept"] = "text/event-stream"
|
143
|
+
req.headers["Cache-Control"] = "no-cache"
|
144
|
+
|
145
|
+
req.options.on_data = Proc.new do |chunk, bytes|
|
146
|
+
handle_sse_data(chunk, bytes)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
# This should never be reached during normal operation
|
151
|
+
# as the SSE connection stays open
|
152
|
+
rescue Faraday::ConnectionFailed => e
|
153
|
+
handle_connection_error(format_connection_error(e))
|
154
|
+
rescue => e
|
155
|
+
handle_connection_error("Unexpected error: #{e.message}")
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
def format_connection_error(error)
|
160
|
+
if error.message.include?("Connection refused")
|
161
|
+
"Connection refused - server at #{@base_url} is not running or not accepting connections"
|
162
|
+
else
|
163
|
+
"Connection failed: #{error.message}"
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
def handle_connection_error(message)
|
168
|
+
log_error("SSE connection failed: #{message}")
|
169
|
+
|
170
|
+
# Set error and notify waiting threads
|
171
|
+
@connection_mutex.synchronize do
|
172
|
+
@connection_error = message
|
173
|
+
@connection_condition.broadcast
|
174
|
+
end
|
175
|
+
|
176
|
+
@on_error&.call(StandardError.new(message))
|
177
|
+
end
|
178
|
+
|
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)
|
187
|
+
end
|
188
|
+
|
189
|
+
def handle_sse_data(chunk, _overall_bytes)
|
190
|
+
process_chunk(chunk)
|
191
|
+
throw :halt if @stop_requested
|
192
|
+
end
|
193
|
+
|
194
|
+
def process_chunk(chunk)
|
195
|
+
@buffer << chunk
|
196
|
+
# If the buffer does not contain a newline but appears to be a complete JSON object,
|
197
|
+
# flush it as a complete event.
|
198
|
+
if @buffer.strip.start_with?("{") && @buffer.strip.end_with?("}")
|
199
|
+
(@current_event ||= []) << @buffer.strip
|
200
|
+
@buffer = ""
|
201
|
+
return handle_complete_event
|
202
|
+
end
|
203
|
+
process_buffer while @buffer.include?("\n")
|
204
|
+
end
|
205
|
+
|
206
|
+
|
207
|
+
def process_buffer
|
208
|
+
line, _sep, rest = @buffer.partition("\n")
|
209
|
+
@buffer = rest
|
210
|
+
|
211
|
+
if line.strip.empty?
|
212
|
+
handle_complete_event
|
213
|
+
else
|
214
|
+
(@current_event ||= []) << line.strip
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
def handle_complete_event
|
219
|
+
return unless @current_event
|
220
|
+
|
221
|
+
handle_event(@current_event)
|
222
|
+
@current_event = nil
|
223
|
+
end
|
224
|
+
|
225
|
+
def handle_event(lines)
|
226
|
+
event_data = parse_event(lines)
|
227
|
+
process_event(event_data)
|
228
|
+
end
|
229
|
+
|
230
|
+
def parse_event(lines)
|
231
|
+
event_data = { type: "message", data: "" }
|
232
|
+
has_data_prefix = false
|
233
|
+
|
234
|
+
lines.each do |line|
|
235
|
+
if line.start_with?("event:")
|
236
|
+
event_data[:type] = line.split(":", 2)[1].strip
|
237
|
+
elsif line.start_with?("data:")
|
238
|
+
has_data_prefix = true
|
239
|
+
event_data[:data] << line.split(":", 2)[1].strip
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
# If no "data:" prefix was found, treat the entire event as data
|
244
|
+
unless has_data_prefix
|
245
|
+
event_data[:data] = lines.join("\n")
|
246
|
+
end
|
247
|
+
event_data
|
248
|
+
end
|
249
|
+
|
250
|
+
def process_event(event_data)
|
251
|
+
case event_data[:type]
|
252
|
+
when "endpoint" then set_post_endpoint(event_data[:data])
|
253
|
+
when "message" then handle_raw_message(event_data[:data])
|
254
|
+
when "ping" then log_debug("Received ping")
|
255
|
+
else log_error("Unknown event type: #{event_data[:type]}")
|
256
|
+
end
|
257
|
+
end
|
258
|
+
|
259
|
+
# Modify set_post_endpoint to mark connection as ready
|
260
|
+
def set_post_endpoint(endpoint_path)
|
261
|
+
@post_url = build_post_url(endpoint_path)
|
262
|
+
log_info("Received POST endpoint: #{@post_url}")
|
263
|
+
|
264
|
+
# Signal that we have received the endpoint
|
265
|
+
@endpoint_mutex.synchronize do
|
266
|
+
@endpoint_received = true
|
267
|
+
@endpoint_condition.broadcast
|
268
|
+
end
|
269
|
+
|
270
|
+
# Now that we have the endpoint, send initial capabilities
|
271
|
+
send_initial_capabilities
|
272
|
+
end
|
273
|
+
|
274
|
+
def build_post_url(endpoint_path)
|
275
|
+
URI.join(@base_url, endpoint_path).to_s
|
276
|
+
rescue StandardError
|
277
|
+
"#{@base_url}#{endpoint_path}"
|
278
|
+
end
|
279
|
+
|
280
|
+
def validate_post_endpoint
|
281
|
+
raise "MCP endpoint not set (no 'endpoint' event received)" unless @post_url
|
282
|
+
end
|
283
|
+
|
284
|
+
def send_http_request(json_rpc)
|
285
|
+
response = @conn.post(@post_url,
|
286
|
+
json_rpc,
|
287
|
+
{ "Content-Type" => "application/json" }
|
288
|
+
)
|
289
|
+
handle_http_response(response)
|
290
|
+
end
|
291
|
+
|
292
|
+
def handle_http_response(response)
|
293
|
+
unless response.success?
|
294
|
+
log_error("HTTP POST failed: #{response.status} - #{response.body}")
|
295
|
+
end
|
296
|
+
end
|
297
|
+
|
298
|
+
def cleanup_sse_thread
|
299
|
+
return unless @sse_thread
|
300
|
+
|
301
|
+
@sse_thread.join(TIMEOUT) || @sse_thread.kill
|
302
|
+
end
|
303
|
+
|
304
|
+
def user_agent
|
305
|
+
"ActionMCP-sse-client"
|
306
|
+
end
|
307
|
+
end
|
308
|
+
end
|
309
|
+
end
|
@@ -0,0 +1,117 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "open3"
|
4
|
+
module ActionMCP
|
5
|
+
module Transport
|
6
|
+
class StdioClient < TransportBase
|
7
|
+
attr_reader :received_server_message
|
8
|
+
|
9
|
+
def initialize(command, **options)
|
10
|
+
super(**options)
|
11
|
+
@stdin, @stdout, @stderr, @wait_thr = Open3.popen3(command)
|
12
|
+
@threads_started = false
|
13
|
+
@received_server_message = false
|
14
|
+
@capabilities_sent = false
|
15
|
+
end
|
16
|
+
|
17
|
+
def start
|
18
|
+
start_output_threads
|
19
|
+
|
20
|
+
# Just log that connection is established but don't send capabilities yet
|
21
|
+
if @threads_started && @wait_thr.alive?
|
22
|
+
log_info("STDIO connection established")
|
23
|
+
else
|
24
|
+
log_error("Failed to start STDIO threads or process is not alive")
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def send_message(json)
|
29
|
+
log_debug("\e[34m--> #{json}\e[0m")
|
30
|
+
@stdin.puts("#{json}\n\n")
|
31
|
+
end
|
32
|
+
|
33
|
+
def stop
|
34
|
+
cleanup_resources
|
35
|
+
end
|
36
|
+
|
37
|
+
def ready?
|
38
|
+
true
|
39
|
+
end
|
40
|
+
|
41
|
+
# Check if we've received any message from the server
|
42
|
+
def received_server_message?
|
43
|
+
@received_server_message
|
44
|
+
end
|
45
|
+
|
46
|
+
# Mark the client as ready and send initial capabilities if not already sent
|
47
|
+
def mark_ready_and_send_capabilities
|
48
|
+
unless @received_server_message
|
49
|
+
@received_server_message = true
|
50
|
+
log_info("Received first server message")
|
51
|
+
|
52
|
+
# Send initial capabilities if not already sent
|
53
|
+
unless @capabilities_sent
|
54
|
+
log_info("Server is ready, sending initial capabilities...")
|
55
|
+
send_initial_capabilities
|
56
|
+
@capabilities_sent = true
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def start_output_threads
|
64
|
+
@stdout_thread = Thread.new do
|
65
|
+
@stdout.each_line do |line|
|
66
|
+
line = line.chomp
|
67
|
+
# Mark ready and send capabilities when we get any stdout
|
68
|
+
mark_ready_and_send_capabilities
|
69
|
+
|
70
|
+
# Continue with normal message handling
|
71
|
+
handle_raw_message(line)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
@stderr_thread = Thread.new do
|
76
|
+
@stderr.each_line do |line|
|
77
|
+
line = line.chomp
|
78
|
+
log_info(line)
|
79
|
+
|
80
|
+
# Check stderr for server messages
|
81
|
+
if line.include?("MCP Server") || line.include?("running on stdio")
|
82
|
+
mark_ready_and_send_capabilities
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
@threads_started = true
|
88
|
+
end
|
89
|
+
|
90
|
+
def cleanup_resources
|
91
|
+
@stdin.close
|
92
|
+
wait_for_server_exit
|
93
|
+
cleanup_threads
|
94
|
+
end
|
95
|
+
|
96
|
+
def wait_for_server_exit
|
97
|
+
@wait_thr.join(0.5)
|
98
|
+
kill_server if @wait_thr.alive?
|
99
|
+
end
|
100
|
+
|
101
|
+
def kill_server
|
102
|
+
Process.kill("TERM", @wait_thr.pid)
|
103
|
+
rescue StandardError => e
|
104
|
+
log_error("Failed to kill server process: #{e}")
|
105
|
+
end
|
106
|
+
|
107
|
+
def cleanup_threads
|
108
|
+
@stdout_thread&.kill
|
109
|
+
@stderr_thread&.kill
|
110
|
+
end
|
111
|
+
|
112
|
+
def user_agent
|
113
|
+
"ActionMCP-stdio-client"
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module ActionMCP
|
2
|
+
module Transport
|
3
|
+
module Tools
|
4
|
+
def send_tools_list(request_id)
|
5
|
+
tools = format_registry_items(ToolsRegistry.non_abstract)
|
6
|
+
send_jsonrpc_response(request_id, result: { tools: tools })
|
7
|
+
end
|
8
|
+
|
9
|
+
def send_tools_call(request_id, tool_name, arguments, _meta = {})
|
10
|
+
result = ToolsRegistry.tool_call(tool_name, arguments, _meta)
|
11
|
+
send_jsonrpc_response(request_id, result: result)
|
12
|
+
rescue RegistryBase::NotFound
|
13
|
+
send_jsonrpc_response(request_id, error: JsonRpc::JsonRpcError.new(
|
14
|
+
:method_not_found,
|
15
|
+
message: "Tool not found: #{tool_name}"
|
16
|
+
).as_json)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|