actionmcp 0.33.0 → 0.50.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 49bbf32664efb30dff032e7ab8ff3e76d972016cc7cf1a1a966c61cc507d7680
4
- data.tar.gz: 61182b5e6367ecbbe7db4446a8da8a7ae756dd164e6b302a99f5583be64b3c8c
3
+ metadata.gz: 6673ed8cefa0458b617bcb9ceb865c4aef47a5448752cbe230dc950afea1941f
4
+ data.tar.gz: 68b8c888e2983cea309cd9498d1bde1887025d37058deabeb1b844c0ae3213d8
5
5
  SHA512:
6
- metadata.gz: c2eac5bcb5a430ae67223000f3741f15acf4d6e1fbdd9d9f240640768d4da6e9b62d2ca0ec303be327ae818f1af86ad458b22e30fed6312cb710d02710532b8c
7
- data.tar.gz: a39eb66c2dd002fd821c2db65b3f848c2a68e5d219d46efb0736d5501572235021be2d188cffd6783a14d8e0704737f313f9f225c2542c2d5ca047af08b9193e
6
+ metadata.gz: 1493f47a18bcc5892c84efcfd51840b50cb6d69c9834c0d114cdaa8fe4a7f040d2ac9d84aec06a9599f25135e797bf85e056c7f602571fe6c47f07928e3c6aa9
7
+ data.tar.gz: a4d9cf8658e976d05d8330b9349d143cdf49abc281e974d40f9b70d11592ec619d1e55b47f74c5e6e94865d05a7d8c8fae363e1d98d1bed738fa7be2ee5c4ff2
data/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # ActionMCP
2
2
 
3
- **ActionMCP** is a Ruby gem that provides essential tooling for building Model Context Protocol (MCP) capable servers in Ruby on Rails applications.
3
+ **ActionMCP** is a Ruby gem focused on providing Model Context Protocol (MCP) capability to Ruby on Rails applications, specifically as a server.
4
+
5
+ ActionMCP is designed for production Rails environments and does **not** support STDIO transport. STDIO is not included because it is not production-ready and is only suitable for desktop or script-based use cases. Instead, ActionMCP is built for robust, network-based deployments.
6
+
7
+ The client functionality in ActionMCP is intended to connect to remote MCP servers, not to local processes via STDIO.
4
8
 
5
9
  It offers base classes and helpers for creating MCP applications, making it easier to integrate your Ruby/Rails application with the MCP standard.
6
10
 
@@ -16,8 +20,9 @@ MCP allows AI systems to plug into various resources in a consistent, secure way
16
20
 
17
21
  This means an AI (like an LLM) can request information or actions from your application through a well-defined protocol, and your app can provide context or perform tasks for the AI in return.
18
22
 
19
- **ActionMCP** is targeted at developers building MCP-enabled applications.
20
- It simplifies the process of integrating Ruby and Rails apps with the MCP standard by providing a set of base classes and an easy-to-use server interface.
23
+ **ActionMCP** is targeted at developers building MCP-enabled Rails applications. It simplifies the process of integrating Ruby and Rails apps with the MCP standard by providing a set of base classes and an easy-to-use server interface.
24
+
25
+ > **Note:** STDIO transport is not supported in ActionMCP. This gem is focused on production-ready, network-based deployments. STDIO is only suitable for desktop or script-based experimentation and is intentionally excluded.
21
26
 
22
27
  Instead of implementing MCP support from scratch, you can subclass and configure the provided **Prompt**, **Tool**, and **ResourceTemplate** classes to expose your app's functionality to LLMs.
23
28
 
@@ -25,6 +30,8 @@ ActionMCP handles the underlying MCP message format and routing, so you can adhe
25
30
 
26
31
  In short, ActionMCP helps you build an MCP server (the component that exposes capabilities to AI) more quickly and with fewer mistakes.
27
32
 
33
+ > **Client connections:** The client part of ActionMCP is meant to connect to remote MCP servers only. Connecting to local processes (such as via STDIO) is not supported.
34
+
28
35
  ## Installation
29
36
 
30
37
  To start using ActionMCP, add it to your project:
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionMCP
4
- # Handles the unified MCP endpoint for the 2025-03-26 specification.
4
+ # Implements the MCP endpoints according to the 2025-03-26 specification.
5
5
  # Supports GET for server-initiated SSE streams, POST for client messages
6
6
  # (responding with JSON or SSE), and optionally DELETE for session termination.
7
7
  class UnifiedController < MCPController
data/config/routes.rb CHANGED
@@ -2,14 +2,9 @@
2
2
 
3
3
  ActionMCP::Engine.routes.draw do
4
4
  get "/up", to: "/rails/health#show", as: :action_mcp_health_check
5
- # --- Routes for 2024-11-05 Spec (HTTP+SSE) ---
6
- # Kept for backward compatibility
7
- get "/", to: "sse#events", as: :sse_out
8
- post "/", to: "messages#create", as: :sse_in, defaults: { format: "json" }
9
5
 
10
- # --- Routes for 2025-03-26 Spec (Streamable HTTP) ---
11
- mcp_endpoint = ActionMCP.configuration.mcp_endpoint_path
12
- get mcp_endpoint, to: "unified#show", as: :mcp_get
13
- post mcp_endpoint, to: "unified#create", as: :mcp_post
14
- delete mcp_endpoint, to: "unified#destroy", as: :mcp_delete
6
+ # MCP 2025-03-26 Spec routes
7
+ get "/", to: "unified#show", as: :mcp_get
8
+ post "/", to: "unified#create", as: :mcp_post
9
+ delete "/", to: "unified#destroy", as: :mcp_delete
15
10
  end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ConsolidatedMigration < ActiveRecord::Migration[8.0]
4
+ def change
5
+ # Only create tables if they don't exist to avoid deleting existing data
6
+
7
+ # Create sessions table
8
+ unless table_exists?(:action_mcp_sessions)
9
+ create_table :action_mcp_sessions, id: :string do |t|
10
+ t.string :role, null: false, default: 'server', comment: 'The role of the session'
11
+ t.string :status, null: false, default: 'pre_initialize'
12
+ t.datetime :ended_at, comment: 'The time the session ended'
13
+ t.string :protocol_version
14
+ t.jsonb :server_capabilities, comment: 'The capabilities of the server'
15
+ t.jsonb :client_capabilities, comment: 'The capabilities of the client'
16
+ t.jsonb :server_info, comment: 'The information about the server'
17
+ t.jsonb :client_info, comment: 'The information about the client'
18
+ t.boolean :initialized, null: false, default: false
19
+ t.integer :messages_count, null: false, default: 0
20
+ t.integer :sse_event_counter, default: 0, null: false
21
+ t.jsonb :tool_registry, default: []
22
+ t.jsonb :prompt_registry, default: []
23
+ t.jsonb :resource_registry, default: []
24
+ t.timestamps
25
+ end
26
+ end
27
+
28
+ # Create session messages table
29
+ unless table_exists?(:action_mcp_session_messages)
30
+ create_table :action_mcp_session_messages do |t|
31
+ t.references :session, null: false,
32
+ foreign_key: { to_table: :action_mcp_sessions,
33
+ on_delete: :cascade,
34
+ on_update: :cascade,
35
+ name: 'fk_action_mcp_session_messages_session_id' }, type: :string
36
+ t.string :direction, null: false, comment: 'The message recipient', default: 'client'
37
+ t.string :message_type, null: false, comment: 'The type of the message'
38
+ t.string :jsonrpc_id
39
+ t.jsonb :message_json
40
+ t.boolean :is_ping, default: false, null: false, comment: 'Whether the message is a ping'
41
+ t.boolean :request_acknowledged, default: false, null: false
42
+ t.boolean :request_cancelled, null: false, default: false
43
+ t.timestamps
44
+ end
45
+ end
46
+
47
+ # Create session subscriptions table
48
+ unless table_exists?(:action_mcp_session_subscriptions)
49
+ create_table :action_mcp_session_subscriptions do |t|
50
+ t.references :session,
51
+ null: false,
52
+ foreign_key: { to_table: :action_mcp_sessions, on_delete: :cascade },
53
+ type: :string
54
+ t.string :uri, null: false
55
+ t.datetime :last_notification_at
56
+ t.timestamps
57
+ end
58
+ end
59
+
60
+ # Create session resources table
61
+ unless table_exists?(:action_mcp_session_resources)
62
+ create_table :action_mcp_session_resources do |t|
63
+ t.references :session,
64
+ null: false,
65
+ foreign_key: { to_table: :action_mcp_sessions, on_delete: :cascade },
66
+ type: :string
67
+ t.string :uri, null: false
68
+ t.string :name
69
+ t.text :description
70
+ t.string :mime_type, null: false
71
+ t.boolean :created_by_tool, default: false
72
+ t.datetime :last_accessed_at
73
+ t.json :metadata
74
+ t.timestamps
75
+ end
76
+ end
77
+
78
+ # Create SSE events table
79
+ unless table_exists?(:action_mcp_sse_events)
80
+ create_table :action_mcp_sse_events do |t|
81
+ t.references :session, null: false, foreign_key: { to_table: :action_mcp_sessions }, index: true, type: :string
82
+ t.integer :event_id, null: false
83
+ t.text :data, null: false
84
+ t.timestamps
85
+
86
+ # Index for efficiently retrieving events after a given ID for a specific session
87
+ t.index [ :session_id, :event_id ], unique: true
88
+ t.index :created_at # For cleanup of old events
89
+ end
90
+ end
91
+
92
+ # Add missing columns to existing tables if they exist
93
+
94
+ # For action_mcp_sessions
95
+ if table_exists?(:action_mcp_sessions)
96
+ unless column_exists?(:action_mcp_sessions, :sse_event_counter)
97
+ add_column :action_mcp_sessions, :sse_event_counter, :integer, default: 0, null: false
98
+ end
99
+
100
+ unless column_exists?(:action_mcp_sessions, :tool_registry)
101
+ add_column :action_mcp_sessions, :tool_registry, :jsonb, default: []
102
+ end
103
+
104
+ unless column_exists?(:action_mcp_sessions, :prompt_registry)
105
+ add_column :action_mcp_sessions, :prompt_registry, :jsonb, default: []
106
+ end
107
+
108
+ unless column_exists?(:action_mcp_sessions, :resource_registry)
109
+ add_column :action_mcp_sessions, :resource_registry, :jsonb, default: []
110
+ end
111
+ end
112
+
113
+ # For action_mcp_session_messages
114
+ if table_exists?(:action_mcp_session_messages)
115
+ unless column_exists?(:action_mcp_session_messages, :is_ping)
116
+ add_column :action_mcp_session_messages, :is_ping, :boolean, default: false, null: false, comment: 'Whether the message is a ping'
117
+ end
118
+
119
+ unless column_exists?(:action_mcp_session_messages, :request_acknowledged)
120
+ add_column :action_mcp_session_messages, :request_acknowledged, :boolean, default: false, null: false
121
+ end
122
+
123
+ unless column_exists?(:action_mcp_session_messages, :request_cancelled)
124
+ add_column :action_mcp_session_messages, :request_cancelled, :boolean, null: false, default: false
125
+ end
126
+
127
+ if column_exists?(:action_mcp_session_messages, :message_text)
128
+ remove_column :action_mcp_session_messages, :message_text
129
+ end
130
+
131
+ if column_exists?(:action_mcp_session_messages, :direction)
132
+ change_column_comment :action_mcp_session_messages, :direction, 'The message recipient'
133
+ end
134
+ end
135
+ end
136
+
137
+ private
138
+
139
+ def table_exists?(table_name)
140
+ ActionMCP::ApplicationRecord.connection.table_exists?(table_name)
141
+ end
142
+
143
+ def column_exists?(table_name, column_name)
144
+ ActionMCP::ApplicationRecord.connection.column_exists?(table_name, column_name)
145
+ end
146
+ end
data/exe/actionmcp_cli CHANGED
@@ -24,6 +24,8 @@ end
24
24
  # Parse command-line arguments
25
25
  parser = OptionParser.new do |opts|
26
26
  opts.banner = 'Usage: mcp_client ENDPOINT [options]'
27
+ opts.separator ''
28
+ opts.separator 'ENDPOINT must be an HTTP(S) URL (e.g., http://localhost:3000/action_mcp)'
27
29
  opts.on('-l', '--log-level LEVEL', 'Set log level (DEBUG, INFO, WARN, ERROR)') do |l|
28
30
  options[:logging_level] = l.upcase
29
31
  logger.level = begin
@@ -53,6 +55,11 @@ if endpoint.nil?
53
55
  exit 1
54
56
  end
55
57
 
58
+ unless endpoint =~ %r{\Ahttps?://}
59
+ puts "Error: Only HTTP(S) endpoints are supported. STDIO/command endpoints are not allowed."
60
+ exit 1
61
+ end
62
+
56
63
  # Function to generate a unique request ID
57
64
  def generate_request_id
58
65
  SecureRandom.uuid
@@ -129,7 +136,7 @@ def print_help
129
136
  puts 'Otherwise, enter a raw JSON-RPC request to send directly'
130
137
  end
131
138
 
132
- # Initialize and start the client
139
+ # Initialize and start the client (only HTTP(S) endpoints are supported)
133
140
  client = ActionMCP.create_client(endpoint, logger: logger)
134
141
 
135
142
  # Start the transport
@@ -3,27 +3,21 @@
3
3
  module ActionMCP
4
4
  # Creates a client appropriate for the given endpoint.
5
5
  #
6
- # @param endpoint [String] The endpoint to connect to (URL or command).
6
+ # @param endpoint [String] The endpoint to connect to (URL).
7
7
  # @param logger [Logger] The logger to use. Default is Logger.new($stdout).
8
8
  # @param options [Hash] Additional options to pass to the client constructor.
9
9
  #
10
- # @return [Client::SSEClient, Client::StdioClient] An instance of either SSEClient or StdioClient
11
- # depending on the format of the endpoint.
10
+ # @return [Client::SSEClient] An instance of SSEClient for HTTP(S) endpoints.
12
11
  #
13
12
  # @example
14
13
  # client = ActionMCP.create_client("http://127.0.0.1:3001/action_mcp")
15
14
  # client.connect
16
- #
17
- # @example
18
- # client = ActionMCP.create_client("some_command")
19
- # client.execute
20
15
  def self.create_client(endpoint, logger: Logger.new($stdout), **options)
21
16
  if endpoint =~ %r{\Ahttps?://}
22
17
  logger.info("Creating SSE client for endpoint: #{endpoint}")
23
18
  Client::SSEClient.new(endpoint, logger: logger, **options)
24
19
  else
25
- logger.info("Creating STDIO client for command: #{endpoint}")
26
- Client::StdioClient.new(endpoint, logger: logger, **options)
20
+ raise ArgumentError, "Only HTTP(S) endpoints are supported. STDIO and other transports are not supported."
27
21
  end
28
22
  end
29
23
 
@@ -22,8 +22,7 @@ module ActionMCP
22
22
  :logging_level,
23
23
  :active_profile,
24
24
  :profiles,
25
- # --- New Transport Options ---
26
- :mcp_endpoint_path,
25
+ # --- Transport Options ---
27
26
  :sse_heartbeat_interval,
28
27
  :post_response_preference, # :json or :sse
29
28
  :protocol_version,
@@ -40,10 +39,9 @@ module ActionMCP
40
39
  @active_profile = :primary
41
40
  @profiles = default_profiles
42
41
 
43
- @mcp_endpoint_path = "/mcp"
44
42
  @sse_heartbeat_interval = 30
45
43
  @post_response_preference = :json
46
- @protocol_version = "2024-11-05"
44
+ @protocol_version = "2025-03-26"
47
45
 
48
46
  # Resumability defaults
49
47
  @enable_sse_resumability = true
@@ -21,7 +21,7 @@ module ActionMCP
21
21
  ActionMCP::ResourceTemplate.registered_templates.clear
22
22
  end
23
23
 
24
- config.middleware.use JSONRPC_Rails::Middleware::Validator, [ ActionMCP.configuration.mcp_endpoint_path ]
24
+ config.middleware.use JSONRPC_Rails::Middleware::Validator, [ "/" ]
25
25
 
26
26
  # Load MCP profiles during initialization
27
27
  initializer "action_mcp.load_profiles" do
@@ -60,14 +60,10 @@ module ActionMCP
60
60
  annotate(:readOnly, enabled)
61
61
  end
62
62
 
63
- # Return annotations based on protocol version
63
+ # Return annotations for the tool
64
64
  def annotations_for_protocol(protocol_version = nil)
65
- # Only include annotations for 2025+ protocols
66
- if protocol_version.nil? || protocol_version == "2024-11-05"
67
- {}
68
- else
69
- _annotations
70
- end
65
+ # Always include annotations now that we only support 2025+
66
+ _annotations
71
67
  end
72
68
  end
73
69
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "gem_version"
4
4
  module ActionMCP
5
- VERSION = "0.33.0"
5
+ VERSION = "0.50.1"
6
6
 
7
7
  class << self
8
8
  alias version gem_version
data/lib/action_mcp.rb CHANGED
@@ -34,11 +34,25 @@ module ActionMCP
34
34
  require_relative "action_mcp/version"
35
35
  require_relative "action_mcp/client"
36
36
  include Logging
37
- PROTOCOL_VERSION = "2024-11-05" # Default version
38
- CURRENT_VERSION = "2025-03-26" # Current version for the /mcp endpoint
39
- SUPPORTED_VERSIONS = %w[2024-11-05 2025-03-26].freeze
37
+ PROTOCOL_VERSION = "2025-03-26" # Default version
38
+ CURRENT_VERSION = "2025-03-26" # Current version
39
+ SUPPORTED_VERSIONS = %w[2025-03-26].freeze
40
40
  class << self
41
- delegate :server, to: "ActionMCP::Server"
41
+ # Returns a Rack-compatible application for serving MCP requests
42
+ # This makes ActionMCP.server work similar to ActionCable.server
43
+ # @return [#call] A Rack application that can be used with `run ActionMCP.server`
44
+ def server
45
+ @server ||= begin
46
+ # Initialize the actual server for PubSub.
47
+ # The return value is intentionally discarded as only the side effects are needed.
48
+ Server.server
49
+
50
+ # Return the Engine as the Rack application
51
+ # The Engine will handle routing to the UnifiedController
52
+ Engine
53
+ end
54
+ end
55
+
42
56
  # Returns the configuration instance.
43
57
  #
44
58
  # @return [Configuration] the configuration instance
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: actionmcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.33.0
4
+ version: 0.50.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -106,8 +106,6 @@ files:
106
106
  - README.md
107
107
  - Rakefile
108
108
  - app/controllers/action_mcp/mcp_controller.rb
109
- - app/controllers/action_mcp/messages_controller.rb
110
- - app/controllers/action_mcp/sse_controller.rb
111
109
  - app/controllers/action_mcp/unified_controller.rb
112
110
  - app/models/action_mcp.rb
113
111
  - app/models/action_mcp/application_record.rb
@@ -119,14 +117,7 @@ files:
119
117
  - app/models/concerns/mcp_console_helpers.rb
120
118
  - app/models/concerns/mcp_message_inspect.rb
121
119
  - config/routes.rb
122
- - db/migrate/20250308122801_create_action_mcp_sessions.rb
123
- - db/migrate/20250314230152_add_is_ping_to_session_message.rb
124
- - db/migrate/20250316005021_create_action_mcp_session_subscriptions.rb
125
- - db/migrate/20250316005649_create_action_mcp_session_resources.rb
126
- - db/migrate/20250324203409_remove_session_message_text.rb
127
- - db/migrate/20250327124131_add_sse_event_counter_to_action_mcp_sessions.rb
128
- - db/migrate/20250329120300_add_registries_to_sessions.rb
129
- - db/migrate/20250329150312_create_action_mcp_sse_events.rb
120
+ - db/migrate/20250512154359_consolidated_migration.rb
130
121
  - exe/actionmcp_cli
131
122
  - lib/action_mcp.rb
132
123
  - lib/action_mcp/base_response.rb
@@ -147,7 +138,6 @@ files:
147
138
  - lib/action_mcp/client/roots.rb
148
139
  - lib/action_mcp/client/server.rb
149
140
  - lib/action_mcp/client/sse_client.rb
150
- - lib/action_mcp/client/stdio_client.rb
151
141
  - lib/action_mcp/client/toolbox.rb
152
142
  - lib/action_mcp/client/tools.rb
153
143
  - lib/action_mcp/configuration.rb
@@ -1,44 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActionMCP
4
- class MessagesController < MCPController
5
- REQUIRED_PROTOCOL_VERSION = "2024-11-05"
6
-
7
- include Instrumentation::ControllerRuntime
8
-
9
- # @route POST / (sse_in)
10
- def create
11
- handle_post_message(params, response)
12
- head response.status
13
- end
14
-
15
- private
16
-
17
- def transport_handler
18
- Server::TransportHandler.new(mcp_session)
19
- end
20
-
21
- def json_rpc_handler
22
- @json_rpc_handler ||= Server::JsonRpcHandler.new(transport_handler)
23
- end
24
-
25
- def handle_post_message(params, response)
26
- filtered_params = filter_jsonrpc_params(params)
27
- json_rpc_handler.call(filtered_params)
28
- response.status = :accepted
29
- rescue StandardError => _e
30
- response.status = :bad_request
31
- end
32
-
33
- def mcp_session
34
- @mcp_session ||= Session.find_or_create_by(id: params[:session_id])
35
- end
36
-
37
- def filter_jsonrpc_params(params)
38
- # Valid JSON-RPC keys (both request and response)
39
- valid_keys = %w[jsonrpc method params id result error]
40
-
41
- params.to_h.slice(*valid_keys)
42
- end
43
- end
44
- end
@@ -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