actionmcp 0.52.2 → 0.54.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.
@@ -11,16 +11,16 @@ class ConsolidatedMigration < ActiveRecord::Migration[8.0]
11
11
  t.string :status, null: false, default: 'pre_initialize'
12
12
  t.datetime :ended_at, comment: 'The time the session ended'
13
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'
14
+ t.json :server_capabilities, comment: 'The capabilities of the server'
15
+ t.json :client_capabilities, comment: 'The capabilities of the client'
16
+ t.json :server_info, comment: 'The information about the server'
17
+ t.json :client_info, comment: 'The information about the client'
18
18
  t.boolean :initialized, null: false, default: false
19
19
  t.integer :messages_count, null: false, default: 0
20
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: []
21
+ t.json :tool_registry, default: []
22
+ t.json :prompt_registry, default: []
23
+ t.json :resource_registry, default: []
24
24
  t.timestamps
25
25
  end
26
26
  end
@@ -36,7 +36,7 @@ class ConsolidatedMigration < ActiveRecord::Migration[8.0]
36
36
  t.string :direction, null: false, comment: 'The message recipient', default: 'client'
37
37
  t.string :message_type, null: false, comment: 'The type of the message'
38
38
  t.string :jsonrpc_id
39
- t.jsonb :message_json
39
+ t.json :message_json
40
40
  t.boolean :is_ping, default: false, null: false, comment: 'Whether the message is a ping'
41
41
  t.boolean :request_acknowledged, default: false, null: false
42
42
  t.boolean :request_cancelled, null: false, default: false
@@ -98,15 +98,15 @@ class ConsolidatedMigration < ActiveRecord::Migration[8.0]
98
98
  end
99
99
 
100
100
  unless column_exists?(:action_mcp_sessions, :tool_registry)
101
- add_column :action_mcp_sessions, :tool_registry, :jsonb, default: []
101
+ add_column :action_mcp_sessions, :tool_registry, :json, default: []
102
102
  end
103
103
 
104
104
  unless column_exists?(:action_mcp_sessions, :prompt_registry)
105
- add_column :action_mcp_sessions, :prompt_registry, :jsonb, default: []
105
+ add_column :action_mcp_sessions, :prompt_registry, :json, default: []
106
106
  end
107
107
 
108
108
  unless column_exists?(:action_mcp_sessions, :resource_registry)
109
- add_column :action_mcp_sessions, :resource_registry, :jsonb, default: []
109
+ add_column :action_mcp_sessions, :resource_registry, :json, default: []
110
110
  end
111
111
  end
112
112
 
@@ -132,7 +132,10 @@ class ConsolidatedMigration < ActiveRecord::Migration[8.0]
132
132
 
133
133
  return unless column_exists?(:action_mcp_session_messages, :direction)
134
134
 
135
- change_column_comment :action_mcp_session_messages, :direction, 'The message recipient'
135
+ # SQLite3 doesn't support changing column comments
136
+ if connection.adapter_name.downcase != 'sqlite'
137
+ change_column_comment :action_mcp_session_messages, :direction, 'The message recipient'
138
+ end
136
139
  end
137
140
 
138
141
  private
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddOAuthToSessions < ActiveRecord::Migration[8.0]
4
+ def change
5
+ # Use json for all databases (PostgreSQL, SQLite3, MySQL) for consistency
6
+ json_type = :json
7
+
8
+ add_column :action_mcp_sessions, :oauth_access_token, :string
9
+ add_column :action_mcp_sessions, :oauth_refresh_token, :string
10
+ add_column :action_mcp_sessions, :oauth_token_expires_at, :datetime
11
+ add_column :action_mcp_sessions, :oauth_user_context, json_type
12
+ add_column :action_mcp_sessions, :authentication_method, :string, default: "none"
13
+
14
+ # Add indexes for performance
15
+ add_index :action_mcp_sessions, :oauth_access_token, unique: true
16
+ add_index :action_mcp_sessions, :oauth_token_expires_at
17
+ add_index :action_mcp_sessions, :authentication_method
18
+ end
19
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Client
5
+ class OauthClientProvider
6
+ # Simple in-memory storage for development
7
+ # In production, use persistent storage
8
+ class MemoryStorage
9
+ def initialize
10
+ @data = {}
11
+ end
12
+
13
+ def save_tokens(tokens)
14
+ @data[:tokens] = tokens
15
+ end
16
+
17
+ def load_tokens
18
+ @data[:tokens]
19
+ end
20
+
21
+ def clear_tokens
22
+ @data.delete(:tokens)
23
+ end
24
+
25
+ def save_code_verifier(verifier)
26
+ @data[:code_verifier] = verifier
27
+ end
28
+
29
+ def load_code_verifier
30
+ @data[:code_verifier]
31
+ end
32
+
33
+ def clear_code_verifier
34
+ @data.delete(:code_verifier)
35
+ end
36
+
37
+ def save_client_information(info)
38
+ @data[:client_information] = info
39
+ end
40
+
41
+ def load_client_information
42
+ @data[:client_information]
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "pkce_challenge"
5
+ require "securerandom"
6
+ require "uri"
7
+ require "json"
8
+
9
+ module ActionMCP
10
+ module Client
11
+ # OAuth client provider for MCP client authentication
12
+ # Implements OAuth 2.1 authorization code flow with PKCE
13
+ class OauthClientProvider
14
+ class AuthenticationError < StandardError; end
15
+ class TokenExpiredError < StandardError; end
16
+ attr_reader :redirect_url, :client_metadata, :authorization_server_url
17
+
18
+ def initialize(
19
+ authorization_server_url:,
20
+ redirect_url:,
21
+ client_metadata: {},
22
+ storage: nil,
23
+ logger: ActionMCP.logger
24
+ )
25
+ @authorization_server_url = URI(authorization_server_url)
26
+ @redirect_url = URI(redirect_url)
27
+ @client_metadata = default_client_metadata.merge(client_metadata)
28
+ @storage = storage || MemoryStorage.new
29
+ @logger = logger
30
+ @http_client = build_http_client
31
+ end
32
+
33
+ # Get current access token for authorization headers
34
+ def access_token
35
+ tokens = current_tokens
36
+ return nil unless tokens
37
+
38
+ if token_expired?(tokens)
39
+ refresh_tokens! if tokens[:refresh_token]
40
+ tokens = current_tokens
41
+ end
42
+
43
+ tokens&.dig(:access_token)
44
+ end
45
+
46
+ # Check if client has valid authentication
47
+ def authenticated?
48
+ !access_token.nil?
49
+ end
50
+
51
+ # Start OAuth authorization flow
52
+ def start_authorization_flow(scope: nil, state: nil)
53
+ # Generate PKCE challenge
54
+ pkce = PkceChallenge.challenge
55
+ code_verifier = pkce.code_verifier
56
+ code_challenge = pkce.code_challenge
57
+ @storage.save_code_verifier(code_verifier)
58
+
59
+ # Build authorization URL
60
+ auth_params = {
61
+ response_type: "code",
62
+ client_id: client_id,
63
+ redirect_uri: @redirect_url.to_s,
64
+ code_challenge: code_challenge,
65
+ code_challenge_method: "S256"
66
+ }
67
+ auth_params[:scope] = scope if scope
68
+ auth_params[:state] = state if state
69
+
70
+ authorization_url = build_url(server_metadata[:authorization_endpoint], auth_params)
71
+
72
+ log_debug("Starting OAuth flow: #{authorization_url}")
73
+ authorization_url
74
+ end
75
+
76
+ # Complete OAuth flow with authorization code
77
+ def complete_authorization_flow(authorization_code, state: nil)
78
+ code_verifier = @storage.load_code_verifier
79
+ raise AuthenticationError, "No code verifier found" unless code_verifier
80
+
81
+ # Exchange code for tokens
82
+ token_params = {
83
+ grant_type: "authorization_code",
84
+ code: authorization_code,
85
+ redirect_uri: @redirect_url.to_s,
86
+ code_verifier: code_verifier,
87
+ client_id: client_id
88
+ }
89
+
90
+ response = @http_client.post(server_metadata[:token_endpoint]) do |req|
91
+ req.headers["Content-Type"] = "application/x-www-form-urlencoded"
92
+ req.headers["Accept"] = "application/json"
93
+ req.body = URI.encode_www_form(token_params)
94
+ end
95
+
96
+ handle_token_response(response)
97
+ end
98
+
99
+ # Refresh access token using refresh token
100
+ def refresh_tokens!
101
+ tokens = current_tokens
102
+ refresh_token = tokens&.dig(:refresh_token)
103
+ raise TokenExpiredError, "No refresh token available" unless refresh_token
104
+
105
+ token_params = {
106
+ grant_type: "refresh_token",
107
+ refresh_token: refresh_token,
108
+ client_id: client_id
109
+ }
110
+
111
+ response = @http_client.post(server_metadata[:token_endpoint]) do |req|
112
+ req.headers["Content-Type"] = "application/x-www-form-urlencoded"
113
+ req.headers["Accept"] = "application/json"
114
+ req.body = URI.encode_www_form(token_params)
115
+ end
116
+
117
+ handle_token_response(response)
118
+ end
119
+
120
+ # Clear stored tokens (logout)
121
+ def clear_tokens!
122
+ @storage.clear_tokens
123
+ @storage.clear_code_verifier if @storage.respond_to?(:clear_code_verifier)
124
+ log_debug("Cleared OAuth tokens and code verifier")
125
+ end
126
+
127
+ # Get client information for registration
128
+ def client_information
129
+ @storage.load_client_information
130
+ end
131
+
132
+ # Save client information after registration
133
+ def save_client_information(client_info)
134
+ @storage.save_client_information(client_info)
135
+ end
136
+
137
+ # Get authorization headers for HTTP requests
138
+ def authorization_headers
139
+ token = access_token
140
+ return {} unless token
141
+
142
+ { "Authorization" => "Bearer #{token}" }
143
+ end
144
+
145
+ private
146
+
147
+ def current_tokens
148
+ @storage.load_tokens
149
+ end
150
+
151
+ def save_tokens(tokens)
152
+ @storage.save_tokens(tokens)
153
+ end
154
+
155
+ def token_expired?(tokens)
156
+ expires_at = tokens[:expires_at]
157
+ return false unless expires_at
158
+
159
+ Time.at(expires_at) <= Time.now + 30 # 30 second buffer
160
+ end
161
+
162
+ def client_id
163
+ client_info = client_information
164
+ client_info&.dig(:client_id) || @client_metadata[:client_id]
165
+ end
166
+
167
+ def server_metadata
168
+ @server_metadata ||= fetch_server_metadata
169
+ end
170
+
171
+ def fetch_server_metadata
172
+ well_known_url = @authorization_server_url.dup
173
+ well_known_url.path = "/.well-known/oauth-authorization-server"
174
+
175
+ response = @http_client.get(well_known_url)
176
+ unless response.success?
177
+ raise AuthenticationError, "Failed to fetch server metadata: #{response.status}"
178
+ end
179
+
180
+ JSON.parse(response.body, symbolize_names: true)
181
+ end
182
+
183
+ def handle_token_response(response)
184
+ unless response.success?
185
+ error_body = JSON.parse(response.body) rescue {}
186
+ error_msg = error_body["error_description"] || error_body["error"] || "Token request failed"
187
+ raise AuthenticationError, "#{error_msg} (#{response.status})"
188
+ end
189
+
190
+ token_data = JSON.parse(response.body, symbolize_names: true)
191
+
192
+ # Calculate token expiration
193
+ if token_data[:expires_in]
194
+ token_data[:expires_at] = Time.now.to_i + token_data[:expires_in].to_i
195
+ end
196
+
197
+ save_tokens(token_data)
198
+ log_debug("OAuth tokens obtained successfully")
199
+ token_data
200
+ end
201
+
202
+ def build_url(base_url, params)
203
+ uri = URI(base_url)
204
+ uri.query = URI.encode_www_form(params)
205
+ uri.to_s
206
+ end
207
+
208
+ def build_http_client
209
+ Faraday.new do |f|
210
+ f.headers["User-Agent"] = "ActionMCP-OAuth/#{ActionMCP.gem_version}"
211
+ f.options.timeout = 30
212
+ f.options.open_timeout = 10
213
+ f.adapter :net_http
214
+ end
215
+ end
216
+
217
+ def default_client_metadata
218
+ {
219
+ client_name: "ActionMCP Client",
220
+ client_uri: "https://github.com/anthropics/action_mcp",
221
+ redirect_uris: [ @redirect_url.to_s ],
222
+ grant_types: [ "authorization_code", "refresh_token" ],
223
+ response_types: [ "code" ],
224
+ token_endpoint_auth_method: "none", # Public client
225
+ code_challenge_methods_supported: [ "S256" ]
226
+ }
227
+ end
228
+
229
+ def log_debug(message)
230
+ @logger.debug("[ActionMCP::OAuthClientProvider] #{message}")
231
+ end
232
+ end
233
+ end
234
+ end
@@ -15,9 +15,10 @@ module ActionMCP
15
15
 
16
16
  attr_reader :session_id, :last_event_id
17
17
 
18
- def initialize(url, session_store:, session_id: nil, **options)
18
+ def initialize(url, session_store:, session_id: nil, oauth_provider: nil, **options)
19
19
  super(url, session_store: session_store, **options)
20
20
  @session_id = session_id
21
+ @oauth_provider = oauth_provider
21
22
  @last_event_id = nil
22
23
  @buffer = +""
23
24
  @current_event = nil
@@ -97,6 +98,7 @@ module ActionMCP
97
98
  }
98
99
  headers["mcp-session-id"] = @session_id if @session_id
99
100
  headers["Last-Event-ID"] = @last_event_id if @last_event_id
101
+ headers.merge!(oauth_headers)
100
102
  headers
101
103
  end
102
104
 
@@ -106,6 +108,7 @@ module ActionMCP
106
108
  "Accept" => "application/json, text/event-stream"
107
109
  }
108
110
  headers["mcp-session-id"] = @session_id if @session_id
111
+ headers.merge!(oauth_headers)
109
112
  headers
110
113
  end
111
114
 
@@ -190,6 +193,7 @@ module ActionMCP
190
193
  # Accepted - message received, no immediate response
191
194
  log_debug("Message accepted (202)")
192
195
  when 401
196
+ handle_authentication_error(response)
193
197
  raise AuthenticationError, "Authentication required"
194
198
  when 405
195
199
  # Method not allowed - server doesn't support this operation
@@ -283,6 +287,26 @@ module ActionMCP
283
287
  log_debug("Saved session state")
284
288
  end
285
289
 
290
+ def oauth_headers
291
+ return {} unless @oauth_provider&.authenticated?
292
+
293
+ @oauth_provider.authorization_headers
294
+ rescue StandardError => e
295
+ log_error("Failed to get OAuth headers: #{e.message}")
296
+ {}
297
+ end
298
+
299
+ def handle_authentication_error(response)
300
+ return unless @oauth_provider
301
+
302
+ # Check for OAuth challenge in WWW-Authenticate header
303
+ www_auth = response.headers["www-authenticate"]
304
+ if www_auth&.include?("Bearer")
305
+ log_debug("Received OAuth challenge, clearing tokens")
306
+ @oauth_provider.clear_tokens!
307
+ end
308
+ end
309
+
286
310
  def user_agent
287
311
  "ActionMCP-StreamableHTTP/#{ActionMCP.gem_version}"
288
312
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "client/transport"
4
4
  require_relative "client/session_store"
5
5
  require_relative "client/streamable_http_transport"
6
+ require_relative "client/oauth_client_provider"
6
7
 
7
8
  module ActionMCP
8
9
  # Creates a client appropriate for the given endpoint.
@@ -11,6 +12,7 @@ module ActionMCP
11
12
  # @param transport [Symbol] The transport type to use (:streamable_http, :sse for legacy)
12
13
  # @param session_store [Symbol] The session store type (:memory, :active_record)
13
14
  # @param session_id [String] Optional session ID for resuming connections
15
+ # @param oauth_provider [ActionMCP::Client::OauthClientProvider] Optional OAuth provider for authentication
14
16
  # @param logger [Logger] The logger to use. Default is Logger.new($stdout).
15
17
  # @param options [Hash] Additional options to pass to the client constructor.
16
18
  #
@@ -33,7 +35,18 @@ module ActionMCP
33
35
  # "http://127.0.0.1:3001/action_mcp",
34
36
  # session_store: :memory
35
37
  # )
36
- def self.create_client(endpoint, transport: :streamable_http, session_store: nil, session_id: nil, logger: Logger.new($stdout), **options)
38
+ #
39
+ # @example With OAuth authentication
40
+ # oauth_provider = ActionMCP::Client::OauthClientProvider.new(
41
+ # authorization_server_url: "https://oauth.example.com",
42
+ # redirect_url: "http://localhost:3000/callback",
43
+ # client_metadata: { client_name: "My App" }
44
+ # )
45
+ # client = ActionMCP.create_client(
46
+ # "http://127.0.0.1:3001/action_mcp",
47
+ # oauth_provider: oauth_provider
48
+ # )
49
+ def self.create_client(endpoint, transport: :streamable_http, session_store: nil, session_id: nil, oauth_provider: nil, logger: Logger.new($stdout), **options)
37
50
  unless endpoint =~ %r{\Ahttps?://}
38
51
  raise ArgumentError, "Only HTTP(S) endpoints are supported. STDIO and other transports are not supported."
39
52
  end
@@ -42,7 +55,7 @@ module ActionMCP
42
55
  store = Client::SessionStoreFactory.create(session_store, **options)
43
56
 
44
57
  # Create transport
45
- transport_instance = create_transport(transport, endpoint, session_store: store, session_id: session_id, logger: logger, **options)
58
+ transport_instance = create_transport(transport, endpoint, session_store: store, session_id: session_id, oauth_provider: oauth_provider, logger: logger, **options)
46
59
 
47
60
  logger.info("Creating #{transport} client for endpoint: #{endpoint}")
48
61
  # Pass session_id to the client
@@ -25,6 +25,9 @@ module ActionMCP
25
25
  :logging_level,
26
26
  :active_profile,
27
27
  :profiles,
28
+ # --- Authentication Options ---
29
+ :authentication_methods,
30
+ :oauth_config,
28
31
  # --- Transport Options ---
29
32
  :sse_heartbeat_interval,
30
33
  :post_response_preference, # :json or :sse
@@ -36,9 +39,15 @@ module ActionMCP
36
39
  :max_stored_sse_events,
37
40
  # --- Gateway Options ---
38
41
  :gateway_class,
39
- :current_class,
40
42
  # --- Session Store Options ---
41
- :session_store_type
43
+ :session_store_type,
44
+ # --- Pub/Sub and Thread Pool Options ---
45
+ :adapter,
46
+ :min_threads,
47
+ :max_threads,
48
+ :max_queue,
49
+ :polling_interval,
50
+ :connects_to
42
51
 
43
52
  def initialize
44
53
  @logging_enabled = true
@@ -47,6 +56,11 @@ module ActionMCP
47
56
  @resources_subscribe = false
48
57
  @active_profile = :primary
49
58
  @profiles = default_profiles
59
+
60
+ # Authentication defaults
61
+ @authentication_methods = Rails.env.production? ? [ "jwt" ] : [ "none" ]
62
+ @oauth_config = {}
63
+
50
64
  @sse_heartbeat_interval = 30
51
65
  @post_response_preference = :json
52
66
  @protocol_version = "2025-03-26"
@@ -58,7 +72,6 @@ module ActionMCP
58
72
 
59
73
  # Gateway - default to ApplicationGateway if it exists, otherwise ActionMCP::Gateway
60
74
  @gateway_class = defined?(::ApplicationGateway) ? ::ApplicationGateway : ActionMCP::Gateway
61
- @current_class = nil
62
75
 
63
76
  # Session Store
64
77
  @session_store_type = Rails.env.production? ? :active_record : :volatile
@@ -77,7 +90,7 @@ module ActionMCP
77
90
  ActionMCP.thread_profiles.value || @active_profile
78
91
  end
79
92
 
80
- # Load custom profiles from Rails configuration
93
+ # Load custom configuration from Rails configuration
81
94
  def load_profiles
82
95
  # First load defaults from the gem
83
96
  @profiles = default_profiles
@@ -88,8 +101,23 @@ module ActionMCP
88
101
 
89
102
  raise "Invalid MCP config file" unless app_config.is_a?(Hash)
90
103
 
91
- # Merge with defaults so user config overrides gem defaults
92
- @profiles = app_config
104
+ # Extract authentication configuration if present
105
+ if app_config["authentication"]
106
+ @authentication_methods = Array(app_config["authentication"])
107
+ end
108
+
109
+ # Extract OAuth configuration if present
110
+ if app_config["oauth"]
111
+ @oauth_config = app_config["oauth"]
112
+ end
113
+
114
+ # Extract other top-level configuration settings
115
+ extract_top_level_settings(app_config)
116
+
117
+ # Extract profiles configuration
118
+ if app_config["profiles"]
119
+ @profiles = app_config["profiles"]
120
+ end
93
121
  rescue StandardError
94
122
  # If the config file doesn't exist in the Rails app, just use the defaults
95
123
  Rails.logger.debug "No MCP config found in Rails app, using defaults from gem"
@@ -197,6 +225,37 @@ module ActionMCP
197
225
  }
198
226
  end
199
227
 
228
+ def extract_top_level_settings(app_config)
229
+ # Extract adapter configuration
230
+ if app_config["adapter"]
231
+ # This will be handled by the pub/sub system, we just store it for now
232
+ @adapter = app_config["adapter"]
233
+ end
234
+
235
+ # Extract thread pool settings
236
+ if app_config["min_threads"]
237
+ @min_threads = app_config["min_threads"]
238
+ end
239
+
240
+ if app_config["max_threads"]
241
+ @max_threads = app_config["max_threads"]
242
+ end
243
+
244
+ if app_config["max_queue"]
245
+ @max_queue = app_config["max_queue"]
246
+ end
247
+
248
+ # Extract polling interval for solid_cable
249
+ if app_config["polling_interval"]
250
+ @polling_interval = app_config["polling_interval"]
251
+ end
252
+
253
+ # Extract connects_to setting
254
+ if app_config["connects_to"]
255
+ @connects_to = app_config["connects_to"]
256
+ end
257
+ end
258
+
200
259
  def should_include_all?(type)
201
260
  return false unless @profiles[active_profile]
202
261
 
@@ -12,6 +12,7 @@ module ActionMCP
12
12
  ActiveSupport::Inflector.inflections(:en) do |inflect|
13
13
  inflect.acronym "SSE"
14
14
  inflect.acronym "MCP"
15
+ inflect.acronym "OAuth"
15
16
  end
16
17
 
17
18
  # Provide a configuration namespace for ActionMCP
@@ -28,6 +29,13 @@ module ActionMCP
28
29
  ActionMCP.configuration.load_profiles
29
30
  end
30
31
 
32
+ # Add OAuth middleware if OAuth is configured
33
+ initializer "action_mcp.oauth_middleware", after: "action_mcp.load_profiles" do
34
+ if ActionMCP.configuration.authentication_methods&.include?("oauth")
35
+ config.middleware.use ActionMCP::OAuth::Middleware
36
+ end
37
+ end
38
+
31
39
  # Configure autoloading for the mcp/tools directory
32
40
  initializer "action_mcp.autoloading", before: :set_autoload_paths do |app|
33
41
  mcp_path = app.root.join("app/mcp")