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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +138 -4
  3. data/app/controllers/action_mcp/unified_controller.rb +1 -1
  4. data/config/routes.rb +4 -9
  5. data/db/migrate/20250512154359_consolidated_migration.rb +146 -0
  6. data/exe/actionmcp_cli +8 -1
  7. data/lib/action_mcp/client.rb +3 -9
  8. data/lib/action_mcp/configuration.rb +2 -4
  9. data/lib/action_mcp/engine.rb +1 -1
  10. data/lib/action_mcp/server/configuration.rb +63 -0
  11. data/lib/action_mcp/server/simple_pub_sub.rb +145 -0
  12. data/lib/action_mcp/server/solid_cable_adapter.rb +222 -0
  13. data/lib/action_mcp/server.rb +84 -2
  14. data/lib/action_mcp/sse_listener.rb +3 -3
  15. data/lib/action_mcp/tool.rb +3 -7
  16. data/lib/action_mcp/version.rb +1 -1
  17. data/lib/action_mcp.rb +3 -3
  18. data/lib/generators/action_mcp/config/config_generator.rb +29 -0
  19. data/lib/generators/action_mcp/config/templates/mcp.yml +36 -0
  20. metadata +21 -26
  21. data/app/controllers/action_mcp/messages_controller.rb +0 -44
  22. data/app/controllers/action_mcp/sse_controller.rb +0 -179
  23. data/db/migrate/20250308122801_create_action_mcp_sessions.rb +0 -32
  24. data/db/migrate/20250314230152_add_is_ping_to_session_message.rb +0 -8
  25. data/db/migrate/20250316005021_create_action_mcp_session_subscriptions.rb +0 -16
  26. data/db/migrate/20250316005649_create_action_mcp_session_resources.rb +0 -25
  27. data/db/migrate/20250324203409_remove_session_message_text.rb +0 -7
  28. data/db/migrate/20250327124131_add_sse_event_counter_to_action_mcp_sessions.rb +0 -7
  29. data/db/migrate/20250329120300_add_registries_to_sessions.rb +0 -9
  30. data/db/migrate/20250329150312_create_action_mcp_sse_events.rb +0 -16
  31. 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,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class RemoveSessionMessageText < ActiveRecord::Migration[8.0]
4
- def up
5
- remove_column :action_mcp_session_messages, :message_text
6
- end
7
- end
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class AddSSEEventCounterToActionMCPSessions < ActiveRecord::Migration[8.0]
4
- def change
5
- add_column :action_mcp_sessions, :sse_event_counter, :integer, default: 0, null: false
6
- end
7
- 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