actionmcp 0.1.2 → 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 +249 -0
- data/lib/action_mcp/configuration.rb +55 -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 +8 -1
- data/lib/action_mcp/content.rb +13 -3
- data/lib/action_mcp/engine.rb +34 -0
- data/lib/action_mcp/gem_version.rb +2 -2
- data/lib/action_mcp/integer_array.rb +17 -0
- data/lib/action_mcp/json_rpc/json_rpc_error.rb +22 -1
- data/lib/action_mcp/json_rpc/notification.rb +13 -6
- data/lib/action_mcp/json_rpc/request.rb +26 -2
- data/lib/action_mcp/json_rpc/response.rb +42 -31
- data/lib/action_mcp/json_rpc.rb +1 -7
- data/lib/action_mcp/json_rpc_handler.rb +106 -0
- data/lib/action_mcp/logging.rb +19 -0
- data/lib/action_mcp/prompt.rb +33 -45
- data/lib/action_mcp/prompts_registry.rb +32 -1
- data/lib/action_mcp/registry_base.rb +72 -40
- data/lib/action_mcp/renderable.rb +54 -0
- data/lib/action_mcp/resource.rb +5 -3
- data/lib/action_mcp/server.rb +10 -0
- data/lib/action_mcp/string_array.rb +14 -0
- data/lib/action_mcp/tool.rb +112 -102
- data/lib/action_mcp/tools_registry.rb +28 -3
- 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 -238
- data/lib/action_mcp/transport_handler.rb +54 -0
- data/lib/action_mcp/version.rb +4 -5
- data/lib/action_mcp.rb +40 -27
- data/lib/generators/action_mcp/install/install_generator.rb +2 -0
- 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 +68 -10
- data/exe/action_mcp_stdio +0 -0
- data/lib/action_mcp/json_rpc/base.rb +0 -12
- data/lib/action_mcp/railtie.rb +0 -27
- data/lib/action_mcp/resources_bank.rb +0 -96
@@ -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
|
@@ -0,0 +1,125 @@
|
|
1
|
+
module ActionMCP
|
2
|
+
module Transport
|
3
|
+
class TransportBase
|
4
|
+
attr_reader :logger, :client_capabilities, :server_capabilities
|
5
|
+
|
6
|
+
def initialize(logger: Logger.new(STDOUT))
|
7
|
+
@logger = logger
|
8
|
+
@on_message = nil
|
9
|
+
@on_error = nil
|
10
|
+
@client_capabilities = default_capabilities
|
11
|
+
@server_capabilities = nil
|
12
|
+
@initialize_request_id = SecureRandom.hex(6)
|
13
|
+
@initialization_sent = false
|
14
|
+
end
|
15
|
+
|
16
|
+
def on_message(&block)
|
17
|
+
@on_message = block
|
18
|
+
end
|
19
|
+
|
20
|
+
def on_error(&block)
|
21
|
+
@on_error = block
|
22
|
+
end
|
23
|
+
|
24
|
+
def send_initial_capabilities
|
25
|
+
return if @initialization_sent
|
26
|
+
|
27
|
+
log_info("Sending client capabilities: #{@client_capabilities}")
|
28
|
+
|
29
|
+
request = JsonRpc::Request.new(
|
30
|
+
id: @initialize_request_id,
|
31
|
+
method: "initialize",
|
32
|
+
params: {
|
33
|
+
protocolVersion: PROTOCOL_VERSION,
|
34
|
+
capabilities: @client_capabilities,
|
35
|
+
clientInfo: {
|
36
|
+
name: user_agent,
|
37
|
+
version: ActionMCP.gem_version.to_s
|
38
|
+
}
|
39
|
+
}
|
40
|
+
)
|
41
|
+
@initialization_sent = true
|
42
|
+
send_message(request.to_json)
|
43
|
+
end
|
44
|
+
|
45
|
+
def handle_initialize_response(response)
|
46
|
+
unless @server_capabilities
|
47
|
+
|
48
|
+
if response.result
|
49
|
+
@server_capabilities = response.result["capabilities"]
|
50
|
+
send_initialized_notification
|
51
|
+
else
|
52
|
+
log_error("Server initialization failed: #{response.error}")
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
protected
|
58
|
+
|
59
|
+
def handle_raw_message(raw)
|
60
|
+
# Debug - log all raw messages
|
61
|
+
log_debug("\e[31m<-- #{raw}\e[0m")
|
62
|
+
|
63
|
+
begin
|
64
|
+
msg_hash = MultiJson.load(raw)
|
65
|
+
response = nil
|
66
|
+
|
67
|
+
if msg_hash.key?("jsonrpc")
|
68
|
+
if msg_hash.key?("id")
|
69
|
+
response = JsonRpc::Response.new(**msg_hash.slice("id", "result", "error").symbolize_keys)
|
70
|
+
else
|
71
|
+
response = JsonRpc::Notification.new(**msg_hash.slice("method", "params").symbolize_keys)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
# Check if this is a response to our initialize request
|
75
|
+
if response && @initialize_request_id && response.id == @initialize_request_id
|
76
|
+
handle_initialize_response(response)
|
77
|
+
else
|
78
|
+
@on_message&.call(response) if response
|
79
|
+
end
|
80
|
+
rescue MultiJson::ParseError => e
|
81
|
+
log_error("JSON parse error: #{e} (raw: #{raw})")
|
82
|
+
@on_error&.call(e) if @on_error
|
83
|
+
rescue StandardError => e
|
84
|
+
log_error("Error handling message: #{e} (raw: #{raw})")
|
85
|
+
@on_error&.call(e) if @on_error
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# Send the initialized notification to the server
|
90
|
+
def send_initialized_notification
|
91
|
+
notification = JsonRpc::Notification.new(
|
92
|
+
method: "initialized"
|
93
|
+
)
|
94
|
+
|
95
|
+
logger.info("Sent initialized notification to server")
|
96
|
+
send_message(notification)
|
97
|
+
end
|
98
|
+
|
99
|
+
def default_capabilities
|
100
|
+
{
|
101
|
+
# Base client capabilities
|
102
|
+
# roots: {}, # Remove from now.
|
103
|
+
}
|
104
|
+
end
|
105
|
+
|
106
|
+
def log_debug(message)
|
107
|
+
@logger.debug("[#{log_prefix}] #{message}")
|
108
|
+
end
|
109
|
+
|
110
|
+
def log_info(message)
|
111
|
+
@logger.info("[#{log_prefix}] #{message}")
|
112
|
+
end
|
113
|
+
|
114
|
+
def log_error(message)
|
115
|
+
@logger.error("[#{log_prefix}] #{message}")
|
116
|
+
end
|
117
|
+
|
118
|
+
private
|
119
|
+
|
120
|
+
def log_prefix
|
121
|
+
self.class.name.split("::").last
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
data/lib/action_mcp/transport.rb
CHANGED
@@ -1,241 +1,4 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
1
|
module ActionMCP
|
4
|
-
|
5
|
-
HEARTBEAT_INTERVAL = 15 # seconds
|
6
|
-
|
7
|
-
def initialize(output_io)
|
8
|
-
# output_io can be any IO-like object where we write events.
|
9
|
-
@output = output_io
|
10
|
-
@output.sync = true
|
11
|
-
end
|
12
|
-
|
13
|
-
# Sends the capabilities JSON-RPC notification.
|
14
|
-
#
|
15
|
-
# @param request_id [String, Integer] The request identifier.
|
16
|
-
def send_capabilities(request_id)
|
17
|
-
capabilities = {}
|
18
|
-
|
19
|
-
# Only include each capability if the corresponding registry is non-empty.
|
20
|
-
capabilities[:tools] = { listChanged: true } if ActionMCP::ToolsRegistry.available_tools.any?
|
21
|
-
|
22
|
-
capabilities[:prompts] = { listChanged: true } if ActionMCP::PromptsRegistry.available_prompts.any?
|
23
|
-
|
24
|
-
capabilities[:resources] = { listChanged: true } if ActionMCP::ResourcesBank.all_resources.any?
|
25
|
-
|
26
|
-
# Add logging capability only if enabled by configuration.
|
27
|
-
capabilities[:logging] = {} if ActionMCP.configuration.logging_enabled
|
28
|
-
|
29
|
-
payload = {
|
30
|
-
protocolVersion: "2024-11-05",
|
31
|
-
capabilities: capabilities,
|
32
|
-
serverInfo: {
|
33
|
-
name: ActionMCP.configuration.name,
|
34
|
-
version: ActionMCP.configuration.version
|
35
|
-
}
|
36
|
-
}
|
37
|
-
send_jsonrpc_response(request_id, result: payload)
|
38
|
-
end
|
39
|
-
|
40
|
-
# Sends the tools list JSON-RPC notification.
|
41
|
-
#
|
42
|
-
# @param request_id [String, Integer] The request identifier.
|
43
|
-
def send_tools_list(request_id)
|
44
|
-
tools = format_registry_items(ActionMCP::ToolsRegistry.available_tools)
|
45
|
-
send_jsonrpc_response(request_id, result: { tools: tools })
|
46
|
-
end
|
47
|
-
|
48
|
-
# Sends the resources list JSON-RPC response.
|
49
|
-
#
|
50
|
-
# @param request_id [String, Integer] The request identifier.
|
51
|
-
def send_resources_list(request_id)
|
52
|
-
resources = ActionMCP::ResourcesBank.all_resources # fetch all resources
|
53
|
-
result_data = { "resources" => resources }
|
54
|
-
send_jsonrpc_response(request_id, result: result_data)
|
55
|
-
Rails.logger.info("resources/list: Returned #{resources.size} resources.")
|
56
|
-
rescue StandardError => e
|
57
|
-
Rails.logger.error("resources/list failed: #{e.message}")
|
58
|
-
error_obj = JsonRpcError.new(
|
59
|
-
:internal_error,
|
60
|
-
message: "Failed to list resources: #{e.message}"
|
61
|
-
).as_json
|
62
|
-
send_jsonrpc_response(request_id, error: error_obj)
|
63
|
-
end
|
64
|
-
|
65
|
-
# Sends the resource templates list JSON-RPC response.
|
66
|
-
#
|
67
|
-
# @param request_id [String, Integer] The request identifier.
|
68
|
-
def send_resource_templates_list(request_id)
|
69
|
-
templates = ActionMCP::ResourcesBank.all_templates # get all resource templates
|
70
|
-
result_data = { "resourceTemplates" => templates }
|
71
|
-
send_jsonrpc_response(request_id, result: result_data)
|
72
|
-
Rails.logger.info("resources/templates/list: Returned #{templates.size} resource templates.")
|
73
|
-
rescue StandardError => e
|
74
|
-
Rails.logger.error("resources/templates/list failed: #{e.message}")
|
75
|
-
error_obj = JsonRpcError.new(
|
76
|
-
:internal_error,
|
77
|
-
message: "Failed to list resource templates: #{e.message}"
|
78
|
-
).as_json
|
79
|
-
send_jsonrpc_response(request_id, error: error_obj)
|
80
|
-
end
|
81
|
-
|
82
|
-
# Sends the resource read JSON-RPC response.
|
83
|
-
#
|
84
|
-
# @param request_id [String, Integer] The request identifier.
|
85
|
-
# @param params [Hash] The parameters including the 'uri' for the resource.
|
86
|
-
def send_resource_read(request_id, params)
|
87
|
-
uri = params&.fetch("uri", nil)
|
88
|
-
if uri.nil? || uri.empty?
|
89
|
-
Rails.logger.error("resources/read: 'uri' parameter is missing")
|
90
|
-
error_obj = JsonRpcError.new(
|
91
|
-
:invalid_params,
|
92
|
-
message: "Missing 'uri' parameter for resources/read"
|
93
|
-
).as_json
|
94
|
-
return send_jsonrpc_response(request_id, error: error_obj)
|
95
|
-
end
|
96
|
-
|
97
|
-
begin
|
98
|
-
content = ActionMCP::ResourcesBank.read(uri) # Expecting an instance of an ActionMCP::Content subclass
|
99
|
-
if content.nil?
|
100
|
-
Rails.logger.error("resources/read: Resource not found for URI #{uri}")
|
101
|
-
error_obj = JsonRpcError.new(
|
102
|
-
:invalid_params,
|
103
|
-
message: "Resource not found: #{uri}"
|
104
|
-
).as_json
|
105
|
-
return send_jsonrpc_response(request_id, error: error_obj)
|
106
|
-
end
|
107
|
-
|
108
|
-
# Use the content object's `to_h` to build the JSON-RPC result.
|
109
|
-
result_data = { "contents" => [ content.to_h ] }
|
110
|
-
send_jsonrpc_response(request_id, result: result_data)
|
111
|
-
|
112
|
-
log_msg = "resources/read: Successfully read content of #{uri}"
|
113
|
-
log_msg += " (#{content.text.size} bytes)" if content.respond_to?(:text) && content.text
|
114
|
-
Rails.logger.info(log_msg)
|
115
|
-
rescue StandardError => e
|
116
|
-
Rails.logger.error("resources/read: Error reading #{uri} - #{e.message}")
|
117
|
-
error_obj = JsonRpcError.new(
|
118
|
-
:internal_error,
|
119
|
-
message: "Failed to read resource: #{e.message}"
|
120
|
-
).as_json
|
121
|
-
send_jsonrpc_response(request_id, error: error_obj)
|
122
|
-
end
|
123
|
-
end
|
124
|
-
|
125
|
-
# Sends a call to a tool. Currently logs the call details.
|
126
|
-
#
|
127
|
-
# @param request_id [String, Integer] The request identifier.
|
128
|
-
# @param tool_name [String] The name of the tool.
|
129
|
-
# @param params [Hash] The parameters for the tool.
|
130
|
-
def send_tools_call(request_id, tool_name, params)
|
131
|
-
ActionMCP::ToolsRegistry.fetch_available_tool(tool_name.to_s)
|
132
|
-
Rails.logger.info("Sending tool call: #{tool_name} with params: #{params}")
|
133
|
-
# TODO: Implement tool call handling and response if needed.
|
134
|
-
rescue StandardError => e
|
135
|
-
Rails.logger.error("tools/call: Failed to call tool #{tool_name} - #{e.message}")
|
136
|
-
error_obj = JsonRpcError.new(
|
137
|
-
:internal_error,
|
138
|
-
message: "Failed to call tool #{tool_name}: #{e.message}"
|
139
|
-
).as_json
|
140
|
-
send_jsonrpc_response(request_id, error: error_obj)
|
141
|
-
end
|
142
|
-
|
143
|
-
# Sends the prompts list JSON-RPC notification.
|
144
|
-
#
|
145
|
-
# @param request_id [String, Integer] The request identifier.
|
146
|
-
def send_prompts_list(request_id)
|
147
|
-
prompts = format_registry_items(ActionMCP::PromptsRegistry.available_prompts)
|
148
|
-
send_jsonrpc_response(request_id, result: { prompts: prompts })
|
149
|
-
rescue StandardError => e
|
150
|
-
Rails.logger.error("prompts/list failed: #{e.message}")
|
151
|
-
error_obj = JsonRpcError.new(
|
152
|
-
:internal_error,
|
153
|
-
message: "Failed to list prompts: #{e.message}"
|
154
|
-
).as_json
|
155
|
-
send_jsonrpc_response(request_id, error: error_obj)
|
156
|
-
end
|
157
|
-
|
158
|
-
def send_prompts_get(request_id, params)
|
159
|
-
prompt_name = params&.fetch("name", nil)
|
160
|
-
if prompt_name.nil? || prompt_name.strip.empty?
|
161
|
-
Rails.logger.error("prompts/get: 'name' parameter is missing")
|
162
|
-
error_obj = JsonRpcError.new(
|
163
|
-
:invalid_params,
|
164
|
-
message: "Missing 'name' parameter for prompts/get"
|
165
|
-
).as_json
|
166
|
-
return send_jsonrpc_response(request_id, error: error_obj)
|
167
|
-
end
|
168
|
-
|
169
|
-
begin
|
170
|
-
# Assume a method similar to fetch_available_tool exists for prompts.
|
171
|
-
prompt = ActionMCP::PromptsRegistry.fetch_available_prompt(prompt_name.to_s)
|
172
|
-
if prompt.nil?
|
173
|
-
Rails.logger.error("prompts/get: Prompt not found for name #{prompt_name}")
|
174
|
-
error_obj = JsonRpcError.new(
|
175
|
-
:invalid_params,
|
176
|
-
message: "Prompt not found: #{prompt_name}"
|
177
|
-
).as_json
|
178
|
-
return send_jsonrpc_response(request_id, error: error_obj)
|
179
|
-
end
|
180
|
-
|
181
|
-
result_data = { "prompt" => prompt.to_h }
|
182
|
-
send_jsonrpc_response(request_id, result: result_data)
|
183
|
-
Rails.logger.info("prompts/get: Returned prompt #{prompt_name}")
|
184
|
-
rescue StandardError => e
|
185
|
-
Rails.logger.error("prompts/get: Error retrieving prompt #{prompt_name} - #{e.message}")
|
186
|
-
error_obj = JsonRpcError.new(
|
187
|
-
:internal_error,
|
188
|
-
message: "Failed to get prompt: #{e.message}"
|
189
|
-
).as_json
|
190
|
-
send_jsonrpc_response(request_id, error: error_obj)
|
191
|
-
end
|
192
|
-
end
|
193
|
-
|
194
|
-
# Sends a JSON-RPC pong response.
|
195
|
-
# We don't actually to send any data back because the spec are not fun anymore.
|
196
|
-
#
|
197
|
-
# @param request_id [String, Integer] The request identifier.
|
198
|
-
def send_pong(request_id)
|
199
|
-
send_jsonrpc_response(request_id, result: {})
|
200
|
-
end
|
201
|
-
|
202
|
-
# Sends a JSON-RPC response.
|
203
|
-
#
|
204
|
-
# @param request_id [String, Integer] The request identifier.
|
205
|
-
# @param result [Object] The result data.
|
206
|
-
# @param error [Object, nil] The error data, if any.
|
207
|
-
def send_jsonrpc_response(request_id, result: nil, error: nil)
|
208
|
-
response = JsonRpc::Response.new(id: request_id, result: result, error: error)
|
209
|
-
write_message(response.to_json)
|
210
|
-
end
|
211
|
-
|
212
|
-
# Sends a generic JSON-RPC notification (no response expected).
|
213
|
-
#
|
214
|
-
# @param method [String] The JSON-RPC method.
|
215
|
-
# @param params [Hash] The parameters for the method.
|
216
|
-
def send_jsonrpc_notification(method, params = {})
|
217
|
-
notification = JsonRpc::Notification.new(method: method, params: params)
|
218
|
-
write_message(notification.to_json)
|
219
|
-
end
|
220
|
-
|
221
|
-
private
|
222
|
-
|
223
|
-
# Formats registry items to a hash representation.
|
224
|
-
#
|
225
|
-
# @param registry [Hash] The registry containing tool or prompt definitions.
|
226
|
-
# @return [Array<Hash>] The formatted registry items.
|
227
|
-
def format_registry_items(registry)
|
228
|
-
registry.map { |_, item| item[:class].to_h }
|
229
|
-
end
|
230
|
-
|
231
|
-
# Writes a message to the output IO.
|
232
|
-
#
|
233
|
-
# @param data [String] The data to write.
|
234
|
-
def write_message(data)
|
235
|
-
Rails.logger.debug("Response Sent: #{data}")
|
236
|
-
@output.write("#{data}\n")
|
237
|
-
rescue IOError => e
|
238
|
-
Rails.logger.error("Failed to write message: #{e.message}")
|
239
|
-
end
|
2
|
+
module Transport
|
240
3
|
end
|
241
4
|
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
class TransportHandler
|
5
|
+
include Logging
|
6
|
+
|
7
|
+
include Transport::Capabilities
|
8
|
+
include Transport::Tools
|
9
|
+
include Transport::Prompts
|
10
|
+
include Transport::Messaging
|
11
|
+
|
12
|
+
HEARTBEAT_INTERVAL = 15 # seconds
|
13
|
+
attr_reader :initialized
|
14
|
+
|
15
|
+
def initialize(output_io)
|
16
|
+
@output = output_io
|
17
|
+
@output.sync = true if @output.respond_to?(:sync=)
|
18
|
+
@initialized = false
|
19
|
+
@client_capabilities = {}
|
20
|
+
@client_info = {}
|
21
|
+
@protocol_version = ""
|
22
|
+
end
|
23
|
+
|
24
|
+
def send_ping
|
25
|
+
send_jsonrpc_request("ping")
|
26
|
+
end
|
27
|
+
|
28
|
+
def send_pong(request_id)
|
29
|
+
send_jsonrpc_response(request_id, result: {})
|
30
|
+
end
|
31
|
+
|
32
|
+
def initialized?
|
33
|
+
@initialized
|
34
|
+
end
|
35
|
+
|
36
|
+
def initialized!
|
37
|
+
@initialized = true
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def write_message(data)
|
43
|
+
Timeout.timeout(5) do
|
44
|
+
@output.write("#{data}\n")
|
45
|
+
end
|
46
|
+
rescue Timeout::Error
|
47
|
+
# ActionMCP.logger.error("Write operation timed out")
|
48
|
+
end
|
49
|
+
|
50
|
+
def format_registry_items(registry)
|
51
|
+
registry.map { |item| item.klass.to_h }
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
data/lib/action_mcp/version.rb
CHANGED
@@ -1,11 +1,10 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "gem_version"
|
4
|
-
|
5
4
|
module ActionMCP
|
6
|
-
VERSION = "0.
|
7
|
-
|
8
|
-
|
9
|
-
gem_version
|
5
|
+
VERSION = "0.2.3"
|
6
|
+
|
7
|
+
class << self
|
8
|
+
alias version gem_version
|
10
9
|
end
|
11
10
|
end
|
data/lib/action_mcp.rb
CHANGED
@@ -2,52 +2,65 @@
|
|
2
2
|
|
3
3
|
require "rails"
|
4
4
|
require "active_support"
|
5
|
-
require "
|
6
|
-
require "
|
7
|
-
require "
|
5
|
+
require "active_support/rails"
|
6
|
+
require "multi_json"
|
7
|
+
require "concurrent"
|
8
|
+
require "active_record/railtie"
|
9
|
+
require "action_controller/railtie"
|
10
|
+
require "action_cable/engine"
|
11
|
+
require "action_mcp/engine"
|
12
|
+
require "zeitwerk"
|
8
13
|
|
9
|
-
|
10
|
-
inflect.acronym "MCP"
|
11
|
-
end
|
12
|
-
module ActionMCP
|
13
|
-
extend ActiveSupport::Autoload
|
14
|
-
|
15
|
-
autoload :RegistryBase
|
16
|
-
autoload :Resource
|
17
|
-
autoload :ToolsRegistry
|
18
|
-
autoload :PromptsRegistry
|
19
|
-
autoload :ResourcesBank
|
20
|
-
autoload :Tool
|
21
|
-
autoload :Prompt
|
22
|
-
autoload :JsonRpc
|
23
|
-
eager_autoload do
|
24
|
-
autoload :Configuration
|
25
|
-
end
|
14
|
+
lib = File.dirname(__FILE__)
|
26
15
|
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
16
|
+
Zeitwerk::Loader.for_gem.tap do |loader|
|
17
|
+
loader.ignore(
|
18
|
+
"#{lib}/generators",
|
19
|
+
"#{lib}/action_mcp/version.rb",
|
20
|
+
"#{lib}/action_mcp/gem_version.rb",
|
21
|
+
"#{lib}/actionmcp.rb"
|
22
|
+
)
|
31
23
|
|
32
|
-
|
33
|
-
|
34
|
-
|
24
|
+
loader.inflector.inflect("action_mcp" => "ActionMCP")
|
25
|
+
loader.inflector.inflect("sse_client" => "SSEClient")
|
26
|
+
loader.inflector.inflect("sse_server" => "SSEServer")
|
27
|
+
end.setup
|
28
|
+
|
29
|
+
module ActionMCP
|
30
|
+
require_relative "action_mcp/version"
|
31
|
+
require_relative "action_mcp/configuration"
|
32
|
+
PROTOCOL_VERSION = "2024-11-05"
|
35
33
|
|
36
34
|
module_function
|
37
35
|
|
36
|
+
# Returns the tools registry.
|
37
|
+
#
|
38
|
+
# @return [Hash] the tools registry
|
38
39
|
def tools
|
39
40
|
ToolsRegistry.tools
|
40
41
|
end
|
41
42
|
|
43
|
+
# Returns the prompts registry.
|
44
|
+
#
|
45
|
+
# @return [Hash] the prompts registry
|
42
46
|
def prompts
|
43
47
|
PromptsRegistry.prompts
|
44
48
|
end
|
45
49
|
|
50
|
+
# Returns the available tools.
|
51
|
+
#
|
52
|
+
# @return [ActionMCP::RegistryBase::RegistryScope] the available tools
|
46
53
|
def available_tools
|
47
54
|
ToolsRegistry.available_tools
|
48
55
|
end
|
49
56
|
|
57
|
+
# Returns the available prompts.
|
58
|
+
#
|
59
|
+
# @return [ActionMCP::RegistryBase::RegistryScope] the available prompts
|
50
60
|
def available_prompts
|
51
61
|
PromptsRegistry.available_prompts
|
52
62
|
end
|
63
|
+
|
64
|
+
ActiveModel::Type.register(:string_array, StringArray)
|
65
|
+
ActiveModel::Type.register(:integer_array, IntegerArray)
|
53
66
|
end
|
@@ -1,6 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
# Template for generating new prompts.
|
3
4
|
class <%= class_name %> < ApplicationPrompt
|
5
|
+
# Set the prompt name.
|
4
6
|
prompt_name "<%= prompt_name %>"
|
5
7
|
|
6
8
|
# Provide a user-facing description for your prompt.
|
@@ -13,7 +15,7 @@ class <%= class_name %> < ApplicationPrompt
|
|
13
15
|
# Add validations (note: "Ruby" is not allowed per the validation)
|
14
16
|
validates :language, inclusion: { in: %w[Ruby C Cobol FORTRAN] }
|
15
17
|
|
18
|
+
# Implement your prompt's behavior here
|
16
19
|
def call
|
17
|
-
# Implement your prompt's behavior here
|
18
20
|
end
|
19
21
|
end
|