actionmcp 0.32.1 → 0.50.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.
- checksums.yaml +4 -4
- data/README.md +138 -4
- data/app/controllers/action_mcp/unified_controller.rb +1 -1
- data/config/routes.rb +4 -9
- data/db/migrate/20250512154359_consolidated_migration.rb +146 -0
- data/exe/actionmcp_cli +8 -1
- data/lib/action_mcp/client.rb +3 -9
- data/lib/action_mcp/configuration.rb +2 -4
- data/lib/action_mcp/engine.rb +1 -1
- data/lib/action_mcp/server/configuration.rb +63 -0
- data/lib/action_mcp/server/simple_pub_sub.rb +145 -0
- data/lib/action_mcp/server/solid_cable_adapter.rb +222 -0
- data/lib/action_mcp/server.rb +84 -2
- data/lib/action_mcp/sse_listener.rb +3 -3
- data/lib/action_mcp/tool.rb +3 -7
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +3 -3
- data/lib/generators/action_mcp/config/config_generator.rb +29 -0
- data/lib/generators/action_mcp/config/templates/mcp.yml +36 -0
- metadata +21 -26
- data/app/controllers/action_mcp/messages_controller.rb +0 -44
- data/app/controllers/action_mcp/sse_controller.rb +0 -179
- data/db/migrate/20250308122801_create_action_mcp_sessions.rb +0 -32
- data/db/migrate/20250314230152_add_is_ping_to_session_message.rb +0 -8
- data/db/migrate/20250316005021_create_action_mcp_session_subscriptions.rb +0 -16
- data/db/migrate/20250316005649_create_action_mcp_session_resources.rb +0 -25
- data/db/migrate/20250324203409_remove_session_message_text.rb +0 -7
- data/db/migrate/20250327124131_add_sse_event_counter_to_action_mcp_sessions.rb +0 -7
- data/db/migrate/20250329120300_add_registries_to_sessions.rb +0 -9
- data/db/migrate/20250329150312_create_action_mcp_sse_events.rb +0 -16
- data/lib/action_mcp/client/stdio_client.rb +0 -115
@@ -1,179 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module ActionMCP
|
4
|
-
class SSEController < MCPController
|
5
|
-
REQUIRED_PROTOCOL_VERSION = "2024-11-05"
|
6
|
-
|
7
|
-
HEARTBEAT_INTERVAL = 30 # in seconds
|
8
|
-
INITIAL_CONNECTION_TIMEOUT = 5 # in seconds
|
9
|
-
include ActionController::Live
|
10
|
-
|
11
|
-
# @route GET /sse (sse_out)
|
12
|
-
def events
|
13
|
-
# Set headers for SSE
|
14
|
-
response.headers["X-Accel-Buffering"] = "no"
|
15
|
-
response.headers["Content-Type"] = "text/event-stream"
|
16
|
-
response.headers["Cache-Control"] = "no-cache"
|
17
|
-
response.headers["Connection"] = "keep-alive"
|
18
|
-
|
19
|
-
# Send the endpoint URL to the client
|
20
|
-
send_endpoint_event(sse_in_url)
|
21
|
-
|
22
|
-
Rails.logger.info "SSE: Starting connection for session: #{session_id}"
|
23
|
-
|
24
|
-
# Use Concurrent primitives for state management
|
25
|
-
message_received = Concurrent::AtomicBoolean.new
|
26
|
-
connection_active = Concurrent::AtomicBoolean.new
|
27
|
-
connection_active.make_true
|
28
|
-
|
29
|
-
begin
|
30
|
-
# Create SSE instance
|
31
|
-
sse = SSE.new(response.stream)
|
32
|
-
|
33
|
-
# Start the connection monitor using a proper scheduled task
|
34
|
-
timeout_task = Concurrent::ScheduledTask.execute(INITIAL_CONNECTION_TIMEOUT) do
|
35
|
-
unless message_received.true?
|
36
|
-
Rails.logger.warn "No message received within #{INITIAL_CONNECTION_TIMEOUT} seconds, closing connection for session: #{session_id}"
|
37
|
-
error = build_timeout_error
|
38
|
-
# Safely write error and close the stream
|
39
|
-
Concurrent::Promise.execute do
|
40
|
-
begin
|
41
|
-
sse.write(error)
|
42
|
-
rescue StandardError
|
43
|
-
nil
|
44
|
-
end
|
45
|
-
begin
|
46
|
-
response.stream.close
|
47
|
-
rescue StandardError
|
48
|
-
nil
|
49
|
-
end
|
50
|
-
connection_active.make_false
|
51
|
-
end
|
52
|
-
end
|
53
|
-
end
|
54
|
-
|
55
|
-
# Initialize the listener
|
56
|
-
listener = SSEListener.new(mcp_session)
|
57
|
-
listener_started = listener.start do |message|
|
58
|
-
message_received.make_true
|
59
|
-
sse.write(message)
|
60
|
-
end
|
61
|
-
|
62
|
-
unless listener_started
|
63
|
-
Rails.logger.error "Listener failed to activate for session: #{session_id}"
|
64
|
-
error = build_listener_error
|
65
|
-
sse.write(error)
|
66
|
-
connection_active.make_false
|
67
|
-
return
|
68
|
-
end
|
69
|
-
|
70
|
-
# Create a thread-safe flag to track if we should continue sending heartbeats
|
71
|
-
heartbeat_active = Concurrent::AtomicBoolean.new(true)
|
72
|
-
|
73
|
-
# Setup recurring heartbeat using ScheduledTask with proper cancellation
|
74
|
-
heartbeat_task = nil
|
75
|
-
heartbeat_sender = lambda do
|
76
|
-
if connection_active.true? && !response.stream.closed?
|
77
|
-
begin
|
78
|
-
# Try to send heartbeat with a controlled execution time
|
79
|
-
future = Concurrent::Promises.future do
|
80
|
-
ping_request = JSON_RPC::Request.new(
|
81
|
-
id: SecureRandom.uuid_v7, # Generate a unique ID for each ping
|
82
|
-
method: "ping"
|
83
|
-
).to_h
|
84
|
-
sse.write(ping_request)
|
85
|
-
end
|
86
|
-
|
87
|
-
# Wait for the heartbeat with timeout
|
88
|
-
future.value(5) # 5 second timeout
|
89
|
-
|
90
|
-
# Schedule the next heartbeat if this one succeeded
|
91
|
-
if heartbeat_active.true?
|
92
|
-
heartbeat_task = Concurrent::ScheduledTask.execute(HEARTBEAT_INTERVAL, &heartbeat_sender)
|
93
|
-
end
|
94
|
-
rescue Concurrent::TimeoutError
|
95
|
-
Rails.logger.warn "SSE: Heartbeat timed out, closing connection"
|
96
|
-
connection_active.make_false
|
97
|
-
rescue StandardError => e
|
98
|
-
Rails.logger.debug "SSE: Heartbeat error: #{e.message}"
|
99
|
-
connection_active.make_false
|
100
|
-
end
|
101
|
-
else
|
102
|
-
heartbeat_active.make_false
|
103
|
-
end
|
104
|
-
end
|
105
|
-
|
106
|
-
# Start the first heartbeat
|
107
|
-
heartbeat_task = Concurrent::ScheduledTask.execute(HEARTBEAT_INTERVAL, &heartbeat_sender)
|
108
|
-
|
109
|
-
# Wait for connection to be closed or cancelled
|
110
|
-
sleep 0.1 while connection_active.true? && !response.stream.closed?
|
111
|
-
rescue ActionController::Live::ClientDisconnected, IOError => e
|
112
|
-
Rails.logger.debug "SSE: Client disconnected: #{e.message}"
|
113
|
-
rescue StandardError => e
|
114
|
-
Rails.logger.error "SSE: Unexpected error: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}"
|
115
|
-
ensure
|
116
|
-
# Clean up resources
|
117
|
-
timeout_task&.cancel
|
118
|
-
heartbeat_active&.make_false # Signal to stop scheduling new heartbeats
|
119
|
-
heartbeat_task&.cancel # Cancel any pending heartbeat task
|
120
|
-
listener&.stop
|
121
|
-
begin
|
122
|
-
mcp_session.close!
|
123
|
-
rescue StandardError
|
124
|
-
nil
|
125
|
-
end
|
126
|
-
begin
|
127
|
-
response.stream.close
|
128
|
-
rescue StandardError
|
129
|
-
nil
|
130
|
-
end
|
131
|
-
|
132
|
-
Rails.logger.debug "SSE: Connection cleaned up for session: #{session_id}"
|
133
|
-
end
|
134
|
-
end
|
135
|
-
|
136
|
-
private
|
137
|
-
|
138
|
-
def build_timeout_error
|
139
|
-
JSON_RPC::Response.new(
|
140
|
-
id: SecureRandom.uuid_v7,
|
141
|
-
error: JSON_RPC::JsonRpcError.new(
|
142
|
-
:server_error,
|
143
|
-
message: "No message received within initial connection timeout"
|
144
|
-
).to_h
|
145
|
-
).to_h
|
146
|
-
end
|
147
|
-
|
148
|
-
def build_listener_error
|
149
|
-
JSON_RPC::Response.new(
|
150
|
-
id: SecureRandom.uuid_v7,
|
151
|
-
error: JSON_RPC::JsonRpcError.new(
|
152
|
-
:server_error,
|
153
|
-
message: "Failed to establish server connection"
|
154
|
-
).to_h
|
155
|
-
).to_h
|
156
|
-
end
|
157
|
-
|
158
|
-
def send_endpoint_event(messages_url)
|
159
|
-
endpoint = "#{messages_url}?session_id=#{session_id}"
|
160
|
-
SSE.new(response.stream, event: "endpoint").write(endpoint)
|
161
|
-
end
|
162
|
-
|
163
|
-
def default_url_options
|
164
|
-
{ host: request.host, port: request.port }
|
165
|
-
end
|
166
|
-
|
167
|
-
def mcp_session
|
168
|
-
@mcp_session ||= Session.new
|
169
|
-
end
|
170
|
-
|
171
|
-
def session_id
|
172
|
-
mcp_session.id
|
173
|
-
end
|
174
|
-
|
175
|
-
def cache_key
|
176
|
-
mcp_session.session_key
|
177
|
-
end
|
178
|
-
end
|
179
|
-
end
|
@@ -1,32 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
class CreateActionMCPSessions < ActiveRecord::Migration[8.0]
|
4
|
-
def change
|
5
|
-
create_table :action_mcp_sessions, id: :string do |t|
|
6
|
-
t.string :role, null: false, default: 'server', comment: 'The role of the session'
|
7
|
-
t.string :status, null: false, default: 'pre_initialize'
|
8
|
-
t.datetime :ended_at, comment: 'The time the session ended'
|
9
|
-
t.string :protocol_version
|
10
|
-
t.jsonb :server_capabilities, comment: 'The capabilities of the server'
|
11
|
-
t.jsonb :client_capabilities, comment: 'The capabilities of the client'
|
12
|
-
t.jsonb :server_info, comment: 'The information about the server'
|
13
|
-
t.jsonb :client_info, comment: 'The information about the client'
|
14
|
-
t.boolean :initialized, null: false, default: false
|
15
|
-
t.integer :messages_count, null: false, default: 0
|
16
|
-
t.timestamps
|
17
|
-
end
|
18
|
-
|
19
|
-
create_table :action_mcp_session_messages do |t|
|
20
|
-
t.references :session, null: false, foreign_key: { to_table: :action_mcp_sessions,
|
21
|
-
on_delete: :cascade,
|
22
|
-
on_update: :cascade,
|
23
|
-
name: 'fk_action_mcp_session_messages_session_id' }, type: :string
|
24
|
-
t.string :direction, null: false, comment: 'The session direction', default: 'client'
|
25
|
-
t.string :message_type, null: false, comment: 'The type of the message'
|
26
|
-
t.string :jsonrpc_id
|
27
|
-
t.string :message_text
|
28
|
-
t.jsonb :message_json
|
29
|
-
t.timestamps
|
30
|
-
end
|
31
|
-
end
|
32
|
-
end
|
@@ -1,8 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
class AddIsPingToSessionMessage < ActiveRecord::Migration[8.0]
|
4
|
-
def change
|
5
|
-
add_column :action_mcp_session_messages, :is_ping, :boolean, default: false, null: false
|
6
|
-
add_column :action_mcp_session_messages, :ping_acknowledged, :boolean, default: false, null: false
|
7
|
-
end
|
8
|
-
end
|
@@ -1,16 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
class CreateActionMCPSessionSubscriptions < ActiveRecord::Migration[8.0]
|
4
|
-
def change
|
5
|
-
create_table :action_mcp_session_subscriptions do |t|
|
6
|
-
t.references :session,
|
7
|
-
null: false,
|
8
|
-
foreign_key: { to_table: :action_mcp_sessions, on_delete: :cascade },
|
9
|
-
type: :string
|
10
|
-
t.string :uri, null: false
|
11
|
-
t.datetime :last_notification_at
|
12
|
-
|
13
|
-
t.timestamps
|
14
|
-
end
|
15
|
-
end
|
16
|
-
end
|
@@ -1,25 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
class CreateActionMCPSessionResources < ActiveRecord::Migration[8.0]
|
4
|
-
def change
|
5
|
-
create_table :action_mcp_session_resources do |t|
|
6
|
-
t.references :session,
|
7
|
-
null: false,
|
8
|
-
foreign_key: { to_table: :action_mcp_sessions, on_delete: :cascade },
|
9
|
-
type: :string
|
10
|
-
t.string :uri, null: false
|
11
|
-
t.string :name
|
12
|
-
t.text :description
|
13
|
-
t.string :mime_type, null: false
|
14
|
-
t.boolean :created_by_tool, default: false
|
15
|
-
t.datetime :last_accessed_at
|
16
|
-
t.json :metadata
|
17
|
-
|
18
|
-
t.timestamps
|
19
|
-
end
|
20
|
-
change_column_comment :action_mcp_session_messages, :direction, 'The message recipient'
|
21
|
-
change_column_comment :action_mcp_session_messages, :is_ping, 'Whether the message is a ping'
|
22
|
-
rename_column :action_mcp_session_messages, :ping_acknowledged, :request_acknowledged
|
23
|
-
add_column :action_mcp_session_messages, :request_cancelled, :boolean, null: false, default: false
|
24
|
-
end
|
25
|
-
end
|
@@ -1,9 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
class AddRegistriesToSessions < ActiveRecord::Migration[8.0]
|
4
|
-
def change
|
5
|
-
add_column :action_mcp_sessions, :tool_registry, :jsonb, default: []
|
6
|
-
add_column :action_mcp_sessions, :prompt_registry, :jsonb, default: []
|
7
|
-
add_column :action_mcp_sessions, :resource_registry, :jsonb, default: []
|
8
|
-
end
|
9
|
-
end
|
@@ -1,16 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
class CreateActionMCPSSEEvents < ActiveRecord::Migration[8.0]
|
4
|
-
def change
|
5
|
-
create_table :action_mcp_sse_events do |t|
|
6
|
-
t.references :session, null: false, foreign_key: { to_table: :action_mcp_sessions }, index: true, type: :string
|
7
|
-
t.integer :event_id, null: false
|
8
|
-
t.text :data, null: false
|
9
|
-
t.timestamps
|
10
|
-
|
11
|
-
# Index for efficiently retrieving events after a given ID for a specific session
|
12
|
-
t.index [ :session_id, :event_id ], unique: true
|
13
|
-
t.index :created_at # For cleanup of old events
|
14
|
-
end
|
15
|
-
end
|
16
|
-
end
|
@@ -1,115 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require "open3"
|
4
|
-
|
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
|
13
|
-
@threads_started = false
|
14
|
-
@received_server_message = false
|
15
|
-
@capabilities_sent = false
|
16
|
-
end
|
17
|
-
|
18
|
-
protected
|
19
|
-
|
20
|
-
def start_transport
|
21
|
-
setup_stdio_process
|
22
|
-
start_output_threads
|
23
|
-
|
24
|
-
# Just log that connection is established but don't send capabilities yet
|
25
|
-
if @threads_started && @wait_thr.alive?
|
26
|
-
log_debug("STDIO connection established")
|
27
|
-
true
|
28
|
-
else
|
29
|
-
log_debug("Failed to start STDIO threads or process is not alive")
|
30
|
-
false
|
31
|
-
end
|
32
|
-
end
|
33
|
-
|
34
|
-
def stop_transport
|
35
|
-
cleanup_resources
|
36
|
-
end
|
37
|
-
|
38
|
-
def send_message(json)
|
39
|
-
log_debug("\e[34m--> #{json}\e[0m")
|
40
|
-
@stdin.puts("#{json}\n\n")
|
41
|
-
end
|
42
|
-
|
43
|
-
def ready?
|
44
|
-
@received_server_message
|
45
|
-
end
|
46
|
-
|
47
|
-
private
|
48
|
-
|
49
|
-
def setup_stdio_process
|
50
|
-
@stdin, @stdout, @stderr, @wait_thr = Open3.popen3(@command)
|
51
|
-
end
|
52
|
-
|
53
|
-
def start_output_threads
|
54
|
-
@stdout_thread = Thread.new do
|
55
|
-
@stdout.each_line do |line|
|
56
|
-
line = line.chomp
|
57
|
-
# Mark ready and send capabilities when we get any stdout
|
58
|
-
mark_ready_and_send_capabilities
|
59
|
-
|
60
|
-
# Continue with normal message handling
|
61
|
-
handle_raw_message(line)
|
62
|
-
end
|
63
|
-
end
|
64
|
-
|
65
|
-
@stderr_thread = Thread.new do
|
66
|
-
@stderr.each_line do |line|
|
67
|
-
line = line.chomp
|
68
|
-
|
69
|
-
# Check stderr for server messages
|
70
|
-
mark_ready_and_send_capabilities if line.include?("MCP Server") || line.include?("running on stdio")
|
71
|
-
end
|
72
|
-
end
|
73
|
-
|
74
|
-
@threads_started = true
|
75
|
-
end
|
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
|
-
|
92
|
-
def cleanup_resources
|
93
|
-
@stdin.close
|
94
|
-
wait_for_server_exit
|
95
|
-
cleanup_threads
|
96
|
-
end
|
97
|
-
|
98
|
-
def wait_for_server_exit
|
99
|
-
@wait_thr.join(0.5)
|
100
|
-
kill_server if @wait_thr.alive?
|
101
|
-
end
|
102
|
-
|
103
|
-
def kill_server
|
104
|
-
Process.kill("TERM", @wait_thr.pid)
|
105
|
-
rescue StandardError => e
|
106
|
-
log_error("Failed to kill server process: #{e}")
|
107
|
-
end
|
108
|
-
|
109
|
-
def cleanup_threads
|
110
|
-
@stdout_thread&.kill
|
111
|
-
@stderr_thread&.kill
|
112
|
-
end
|
113
|
-
end
|
114
|
-
end
|
115
|
-
end
|