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 +4 -4
- data/README.md +10 -3
- 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/tool.rb +3 -7
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +18 -4
- metadata +2 -12
- 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
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6673ed8cefa0458b617bcb9ceb865c4aef47a5448752cbe230dc950afea1941f
|
4
|
+
data.tar.gz: 68b8c888e2983cea309cd9498d1bde1887025d37058deabeb1b844c0ae3213d8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
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
|
-
|
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
|
-
#
|
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
|
-
#
|
11
|
-
|
12
|
-
|
13
|
-
|
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
|
data/lib/action_mcp/client.rb
CHANGED
@@ -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
|
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
|
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
|
-
|
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
|
-
# ---
|
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 = "
|
44
|
+
@protocol_version = "2025-03-26"
|
47
45
|
|
48
46
|
# Resumability defaults
|
49
47
|
@enable_sse_resumability = true
|
data/lib/action_mcp/engine.rb
CHANGED
@@ -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, [
|
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
|
data/lib/action_mcp/tool.rb
CHANGED
@@ -60,14 +60,10 @@ module ActionMCP
|
|
60
60
|
annotate(:readOnly, enabled)
|
61
61
|
end
|
62
62
|
|
63
|
-
# Return annotations
|
63
|
+
# Return annotations for the tool
|
64
64
|
def annotations_for_protocol(protocol_version = nil)
|
65
|
-
#
|
66
|
-
|
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
|
|
data/lib/action_mcp/version.rb
CHANGED
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 = "
|
38
|
-
CURRENT_VERSION = "2025-03-26" # Current version
|
39
|
-
SUPPORTED_VERSIONS = %w[
|
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
|
-
|
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.
|
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/
|
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,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
|