actionmcp 0.70.0 → 0.71.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +46 -41
  3. data/app/controllers/action_mcp/application_controller.rb +67 -15
  4. data/app/controllers/action_mcp/oauth/metadata_controller.rb +13 -13
  5. data/app/controllers/action_mcp/oauth/registration_controller.rb +206 -0
  6. data/app/models/action_mcp/oauth_client.rb +157 -0
  7. data/app/models/action_mcp/oauth_token.rb +141 -0
  8. data/app/models/action_mcp/session/message.rb +12 -12
  9. data/app/models/action_mcp/session/resource.rb +2 -2
  10. data/app/models/action_mcp/session/sse_event.rb +2 -2
  11. data/app/models/action_mcp/session/subscription.rb +2 -2
  12. data/app/models/action_mcp/session.rb +22 -22
  13. data/config/routes.rb +1 -0
  14. data/db/migrate/20250708105124_create_action_mcp_oauth_clients.rb +42 -0
  15. data/db/migrate/20250708105226_create_action_mcp_oauth_tokens.rb +37 -0
  16. data/lib/action_mcp/client/base.rb +3 -2
  17. data/lib/action_mcp/client/collection.rb +3 -3
  18. data/lib/action_mcp/client/jwt_client_provider.rb +134 -0
  19. data/lib/action_mcp/client/streamable_http_transport.rb +56 -10
  20. data/lib/action_mcp/client.rb +16 -4
  21. data/lib/action_mcp/configuration.rb +27 -4
  22. data/lib/action_mcp/engine.rb +7 -1
  23. data/lib/action_mcp/filtered_logger.rb +32 -0
  24. data/lib/action_mcp/gateway.rb +47 -133
  25. data/lib/action_mcp/gateway_identifier.rb +29 -0
  26. data/lib/action_mcp/jwt_identifier.rb +28 -0
  27. data/lib/action_mcp/none_identifier.rb +19 -0
  28. data/lib/action_mcp/o_auth_identifier.rb +34 -0
  29. data/lib/action_mcp/oauth/active_record_storage.rb +183 -0
  30. data/lib/action_mcp/oauth/memory_storage.rb +23 -1
  31. data/lib/action_mcp/oauth/middleware.rb +33 -0
  32. data/lib/action_mcp/oauth/provider.rb +49 -13
  33. data/lib/action_mcp/oauth.rb +12 -0
  34. data/lib/action_mcp/server/capabilities.rb +0 -3
  35. data/lib/action_mcp/server/resources.rb +1 -1
  36. data/lib/action_mcp/server/tools.rb +36 -24
  37. data/lib/action_mcp/sse_listener.rb +0 -7
  38. data/lib/action_mcp/test_helper.rb +5 -0
  39. data/lib/action_mcp/tool.rb +94 -4
  40. data/lib/action_mcp/tools_registry.rb +3 -0
  41. data/lib/action_mcp/version.rb +1 -1
  42. data/lib/generators/action_mcp/install/templates/mcp.yml +16 -16
  43. metadata +14 -1
@@ -0,0 +1,42 @@
1
+ class CreateActionMCPOAuthClients < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :action_mcp_oauth_clients do |t|
4
+ t.string :client_id, null: false, index: { unique: true }
5
+ t.string :client_secret
6
+ t.string :client_name
7
+
8
+ # Store arrays as JSON for database compatibility
9
+ if connection.adapter_name.downcase.include?('postgresql')
10
+ t.text :redirect_uris, array: true, default: []
11
+ t.text :grant_types, array: true, default: [ "authorization_code" ]
12
+ t.text :response_types, array: true, default: [ "code" ]
13
+ else
14
+ # For SQLite and other databases, use JSON
15
+ t.json :redirect_uris, default: []
16
+ t.json :grant_types, default: [ "authorization_code" ]
17
+ t.json :response_types, default: [ "code" ]
18
+ end
19
+
20
+ t.string :token_endpoint_auth_method, default: "client_secret_basic"
21
+ t.text :scope
22
+ t.boolean :active, default: true
23
+
24
+ # Registration metadata
25
+ t.integer :client_id_issued_at
26
+ t.integer :client_secret_expires_at
27
+ t.string :registration_access_token # OAuth 2.1 Dynamic Client Registration
28
+
29
+ # Additional metadata as JSON for database compatibility
30
+ if connection.adapter_name.downcase.include?('postgresql')
31
+ t.jsonb :metadata, default: {}
32
+ else
33
+ t.json :metadata, default: {}
34
+ end
35
+
36
+ t.timestamps
37
+ end
38
+
39
+ add_index :action_mcp_oauth_clients, :active
40
+ add_index :action_mcp_oauth_clients, :client_id_issued_at
41
+ end
42
+ end
@@ -0,0 +1,37 @@
1
+ class CreateActionMCPOAuthTokens < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :action_mcp_oauth_tokens do |t|
4
+ t.string :token, null: false, index: { unique: true }
5
+ t.string :token_type, null: false # 'access_token', 'refresh_token', 'authorization_code'
6
+ t.string :client_id, null: false
7
+ t.string :user_id
8
+ t.text :scope
9
+ t.datetime :expires_at
10
+ t.boolean :revoked, default: false
11
+
12
+ # For authorization codes
13
+ t.string :redirect_uri
14
+ t.string :code_challenge
15
+ t.string :code_challenge_method
16
+
17
+ # For refresh tokens
18
+ t.string :access_token # Reference to associated access token
19
+
20
+ # Additional data - use JSON for database compatibility
21
+ if connection.adapter_name.downcase.include?('postgresql')
22
+ t.jsonb :metadata, default: {}
23
+ else
24
+ t.json :metadata, default: {}
25
+ end
26
+
27
+ t.timestamps
28
+ end
29
+
30
+ add_index :action_mcp_oauth_tokens, :token_type
31
+ add_index :action_mcp_oauth_tokens, :client_id
32
+ add_index :action_mcp_oauth_tokens, :user_id
33
+ add_index :action_mcp_oauth_tokens, :expires_at
34
+ add_index :action_mcp_oauth_tokens, :revoked
35
+ add_index :action_mcp_oauth_tokens, [ :token_type, :expires_at ]
36
+ end
37
+ end
@@ -23,11 +23,12 @@ module ActionMCP
23
23
 
24
24
  delegate :connected?, :ready?, to: :transport
25
25
 
26
- def initialize(transport:, logger: ActionMCP.logger, **options)
26
+ def initialize(transport:, logger: ActionMCP.logger, protocol_version: nil, **options)
27
27
  @logger = logger
28
28
  @transport = transport
29
29
  @session = nil # Session will be created/loaded based on server response
30
30
  @session_id = options[:session_id] # Optional session ID for resumption
31
+ @protocol_version = protocol_version || ActionMCP::DEFAULT_PROTOCOL_VERSION
31
32
  @server_capabilities = nil
32
33
  @connection_error = nil
33
34
  @initialized = false
@@ -180,7 +181,7 @@ module ActionMCP
180
181
  end
181
182
 
182
183
  params = {
183
- protocolVersion: ActionMCP::DEFAULT_PROTOCOL_VERSION,
184
+ protocolVersion: @protocol_version,
184
185
  capabilities: client_capabilities,
185
186
  clientInfo: client_info
186
187
  }
@@ -143,14 +143,14 @@ module ActionMCP
143
143
  def silence_logs
144
144
  return yield unless @silence_sql
145
145
 
146
- original_log_level = Session.logger&.level
146
+ original_log_level = ActionMCP::Session.logger&.level
147
147
  begin
148
148
  # Temporarily increase log level to suppress SQL queries
149
- Session.logger.level = Logger::WARN if Session.logger
149
+ ActionMCP::Session.logger.level = Logger::WARN if ActionMCP::Session.logger
150
150
  yield
151
151
  ensure
152
152
  # Restore original log level
153
- Session.logger.level = original_log_level if Session.logger
153
+ ActionMCP::Session.logger.level = original_log_level if ActionMCP::Session.logger && original_log_level
154
154
  end
155
155
  end
156
156
 
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "base64"
5
+
6
+ module ActionMCP
7
+ module Client
8
+ # JWT client provider for MCP client authentication
9
+ # Provides clean JWT token management for ActionMCP client connections
10
+ class JwtClientProvider
11
+ class AuthenticationError < StandardError; end
12
+ class TokenExpiredError < StandardError; end
13
+
14
+ attr_reader :storage
15
+
16
+ def initialize(token: nil, storage: nil, logger: ActionMCP.logger)
17
+ @storage = storage || MemoryStorage.new
18
+ @logger = logger
19
+
20
+ # If token provided during initialization, store it
21
+ if token
22
+ save_token(token)
23
+ end
24
+ end
25
+
26
+ # Check if client has valid authentication
27
+ def authenticated?
28
+ token = current_token
29
+ return false unless token
30
+
31
+ !token_expired?(token)
32
+ end
33
+
34
+ # Get authorization headers for HTTP requests
35
+ def authorization_headers
36
+ token = current_token
37
+ return {} unless token
38
+
39
+ if token_expired?(token)
40
+ log_debug("JWT token expired")
41
+ clear_tokens!
42
+ return {}
43
+ end
44
+
45
+ { "Authorization" => "Bearer #{token}" }
46
+ end
47
+
48
+ # Set/update the JWT token
49
+ def set_token(token)
50
+ save_token(token)
51
+ log_debug("JWT token updated")
52
+ end
53
+
54
+ # Clear stored tokens (logout)
55
+ def clear_tokens!
56
+ @storage.clear_token
57
+ log_debug("Cleared JWT token")
58
+ end
59
+
60
+ # Get current valid token
61
+ def access_token
62
+ token = current_token
63
+ return nil unless token
64
+ return nil if token_expired?(token)
65
+ token
66
+ end
67
+
68
+ private
69
+
70
+ def current_token
71
+ @storage.load_token
72
+ end
73
+
74
+ def save_token(token)
75
+ @storage.save_token(token)
76
+ end
77
+
78
+ def token_expired?(token)
79
+ return false unless token
80
+
81
+ begin
82
+ payload = decode_jwt_payload(token)
83
+ exp = payload["exp"]
84
+ return false unless exp
85
+
86
+ # Add 30 second buffer for clock skew
87
+ Time.at(exp) <= Time.now + 30
88
+ rescue => e
89
+ log_debug("Error checking token expiration: #{e.message}")
90
+ true # Treat invalid tokens as expired
91
+ end
92
+ end
93
+
94
+ def decode_jwt_payload(token)
95
+ # Split JWT into parts
96
+ parts = token.split(".")
97
+ raise AuthenticationError, "Invalid JWT format" unless parts.length == 3
98
+
99
+ # Decode payload (second part)
100
+ payload_base64 = parts[1]
101
+ # Add padding if needed
102
+ payload_base64 += "=" * (4 - payload_base64.length % 4) if payload_base64.length % 4 != 0
103
+
104
+ payload_json = Base64.urlsafe_decode64(payload_base64)
105
+ JSON.parse(payload_json)
106
+ rescue => e
107
+ raise AuthenticationError, "Failed to decode JWT: #{e.message}"
108
+ end
109
+
110
+ def log_debug(message)
111
+ @logger.debug("[ActionMCP::JwtClientProvider] #{message}")
112
+ end
113
+
114
+ # Simple memory storage for JWT tokens
115
+ class MemoryStorage
116
+ def initialize
117
+ @token = nil
118
+ end
119
+
120
+ def save_token(token)
121
+ @token = token
122
+ end
123
+
124
+ def load_token
125
+ @token
126
+ end
127
+
128
+ def clear_token
129
+ @token = nil
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -13,12 +13,15 @@ module ActionMCP
13
13
  SSE_TIMEOUT = 10
14
14
  ENDPOINT_TIMEOUT = 5
15
15
 
16
- attr_reader :session_id, :last_event_id
16
+ attr_reader :session_id, :last_event_id, :protocol_version
17
17
 
18
- def initialize(url, session_store:, session_id: nil, oauth_provider: nil, **options)
18
+ def initialize(url, session_store:, session_id: nil, oauth_provider: nil, jwt_provider: nil, protocol_version: nil, **options)
19
19
  super(url, session_store: session_store, **options)
20
20
  @session_id = session_id
21
21
  @oauth_provider = oauth_provider
22
+ @jwt_provider = jwt_provider
23
+ @protocol_version = protocol_version || ActionMCP::DEFAULT_PROTOCOL_VERSION
24
+ @negotiated_protocol_version = nil
22
25
  @last_event_id = nil
23
26
  @buffer = +""
24
27
  @current_event = nil
@@ -38,8 +41,9 @@ module ActionMCP
38
41
  # Start SSE stream if server supports it
39
42
  start_sse_stream
40
43
 
41
- set_connected(true)
44
+ # Set ready first, then connected (so transport is ready when on_connect fires)
42
45
  set_ready(true)
46
+ set_connected(true)
43
47
  log_debug("StreamableHTTP connection established")
44
48
  true
45
49
  rescue StandardError => e
@@ -98,7 +102,15 @@ module ActionMCP
98
102
  }
99
103
  headers["mcp-session-id"] = @session_id if @session_id
100
104
  headers["Last-Event-ID"] = @last_event_id if @last_event_id
105
+
106
+ # Add MCP-Protocol-Version header for GET requests when we have a negotiated version
107
+ if @negotiated_protocol_version
108
+ headers["MCP-Protocol-Version"] = @negotiated_protocol_version
109
+ end
110
+
101
111
  headers.merge!(oauth_headers)
112
+ headers.merge!(jwt_headers)
113
+ log_debug("Final GET headers: #{headers}")
102
114
  headers
103
115
  end
104
116
 
@@ -108,7 +120,16 @@ module ActionMCP
108
120
  "Accept" => "application/json, text/event-stream"
109
121
  }
110
122
  headers["mcp-session-id"] = @session_id if @session_id
123
+
124
+ # Add MCP-Protocol-Version header as per 2025-06-18 spec
125
+ # Only include when we have a negotiated version from previous handshake
126
+ if @negotiated_protocol_version
127
+ headers["MCP-Protocol-Version"] = @negotiated_protocol_version
128
+ end
129
+
111
130
  headers.merge!(oauth_headers)
131
+ headers.merge!(jwt_headers)
132
+ log_debug("Final POST headers: #{headers}")
112
133
  headers
113
134
  end
114
135
 
@@ -218,6 +239,13 @@ module ActionMCP
218
239
  def handle_json_response(response)
219
240
  begin
220
241
  message = MultiJson.load(response.body)
242
+
243
+ # Check if this is an initialize response to capture negotiated protocol version
244
+ if message.is_a?(Hash) && message["result"] && message["result"]["protocolVersion"]
245
+ @negotiated_protocol_version = message["result"]["protocolVersion"]
246
+ log_debug("Negotiated protocol version: #{@negotiated_protocol_version}")
247
+ end
248
+
221
249
  handle_message(message)
222
250
  rescue MultiJson::ParseError => e
223
251
  log_error("Failed to parse JSON response: #{e}")
@@ -232,7 +260,7 @@ module ActionMCP
232
260
  end
233
261
 
234
262
  def handle_error_response(response)
235
- error_msg = "HTTP #{response.status}: #{response.reason_phrase}"
263
+ error_msg = +"HTTP #{response.status}: #{response.reason_phrase}"
236
264
  if response.body && !response.body.empty?
237
265
  error_msg << " - #{response.body}"
238
266
  end
@@ -280,7 +308,7 @@ module ActionMCP
280
308
  id: @session_id,
281
309
  last_event_id: @last_event_id,
282
310
  session_data: {},
283
- protocol_version: PROTOCOL_VERSION
311
+ protocol_version: @protocol_version
284
312
  }
285
313
 
286
314
  @session_store.save_session(@session_id, session_data)
@@ -290,20 +318,38 @@ module ActionMCP
290
318
  def oauth_headers
291
319
  return {} unless @oauth_provider&.authenticated?
292
320
 
293
- @oauth_provider.authorization_headers
321
+ headers = @oauth_provider.authorization_headers
322
+ log_debug("OAuth headers: #{headers}") unless headers.empty?
323
+ headers
294
324
  rescue StandardError => e
295
325
  log_error("Failed to get OAuth headers: #{e.message}")
296
326
  {}
297
327
  end
298
328
 
299
- def handle_authentication_error(response)
300
- return unless @oauth_provider
329
+ def jwt_headers
330
+ return {} unless @jwt_provider&.authenticated?
301
331
 
332
+ headers = @jwt_provider.authorization_headers
333
+ log_debug("JWT headers: #{headers}") unless headers.empty?
334
+ headers
335
+ rescue StandardError => e
336
+ log_error("Failed to get JWT headers: #{e.message}")
337
+ {}
338
+ end
339
+
340
+ def handle_authentication_error(response)
302
341
  # Check for OAuth challenge in WWW-Authenticate header
303
342
  www_auth = response.headers["www-authenticate"]
304
343
  if www_auth&.include?("Bearer")
305
- log_debug("Received OAuth challenge, clearing tokens")
306
- @oauth_provider.clear_tokens!
344
+ if @oauth_provider
345
+ log_debug("Received OAuth challenge, clearing OAuth tokens")
346
+ @oauth_provider.clear_tokens!
347
+ end
348
+
349
+ if @jwt_provider
350
+ log_debug("Received Bearer challenge, clearing JWT tokens")
351
+ @jwt_provider.clear_tokens!
352
+ end
307
353
  end
308
354
  end
309
355
 
@@ -4,6 +4,7 @@ require_relative "client/transport"
4
4
  require_relative "client/session_store"
5
5
  require_relative "client/streamable_http_transport"
6
6
  require_relative "client/oauth_client_provider"
7
+ require_relative "client/jwt_client_provider"
7
8
 
8
9
  module ActionMCP
9
10
  # Creates a client appropriate for the given endpoint.
@@ -13,6 +14,8 @@ module ActionMCP
13
14
  # @param session_store [Symbol] The session store type (:memory, :active_record)
14
15
  # @param session_id [String] Optional session ID for resuming connections
15
16
  # @param oauth_provider [ActionMCP::Client::OauthClientProvider] Optional OAuth provider for authentication
17
+ # @param jwt_provider [ActionMCP::Client::JwtClientProvider] Optional JWT provider for authentication
18
+ # @param protocol_version [String] The MCP protocol version to use (defaults to ActionMCP::DEFAULT_PROTOCOL_VERSION)
16
19
  # @param logger [Logger] The logger to use. Default is Logger.new($stdout).
17
20
  # @param options [Hash] Additional options to pass to the client constructor.
18
21
  #
@@ -46,7 +49,16 @@ module ActionMCP
46
49
  # "http://127.0.0.1:3001/action_mcp",
47
50
  # oauth_provider: oauth_provider
48
51
  # )
49
- def self.create_client(endpoint, transport: :streamable_http, session_store: nil, session_id: nil, oauth_provider: nil, logger: Logger.new($stdout), **options)
52
+ #
53
+ # @example With JWT authentication
54
+ # jwt_provider = ActionMCP::Client::JwtClientProvider.new(
55
+ # token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
56
+ # )
57
+ # client = ActionMCP.create_client(
58
+ # "http://127.0.0.1:3001/action_mcp",
59
+ # jwt_provider: jwt_provider
60
+ # )
61
+ def self.create_client(endpoint, transport: :streamable_http, session_store: nil, session_id: nil, oauth_provider: nil, jwt_provider: nil, protocol_version: nil, logger: Logger.new($stdout), **options)
50
62
  unless endpoint =~ %r{\Ahttps?://}
51
63
  raise ArgumentError, "Only HTTP(S) endpoints are supported. STDIO and other transports are not supported."
52
64
  end
@@ -55,11 +67,11 @@ module ActionMCP
55
67
  store = Client::SessionStoreFactory.create(session_store, **options)
56
68
 
57
69
  # Create transport
58
- transport_instance = create_transport(transport, endpoint, session_store: store, session_id: session_id, oauth_provider: oauth_provider, logger: logger, **options)
70
+ transport_instance = create_transport(transport, endpoint, session_store: store, session_id: session_id, oauth_provider: oauth_provider, jwt_provider: jwt_provider, protocol_version: protocol_version, logger: logger, **options)
59
71
 
60
72
  logger.info("Creating #{transport} client for endpoint: #{endpoint}")
61
- # Pass session_id to the client
62
- Client::Base.new(transport: transport_instance, logger: logger, session_id: session_id, **options)
73
+ # Pass session_id and protocol_version to the client
74
+ Client::Base.new(transport: transport_instance, logger: logger, session_id: session_id, protocol_version: protocol_version, **options)
63
75
  end
64
76
 
65
77
  private_class_method def self.create_transport(type, endpoint, **options)
@@ -26,6 +26,7 @@ module ActionMCP
26
26
  :active_profile,
27
27
  :profiles,
28
28
  :elicitation_enabled,
29
+ :verbose_logging,
29
30
  # --- Authentication Options ---
30
31
  :authentication_methods,
31
32
  :oauth_config,
@@ -56,12 +57,13 @@ module ActionMCP
56
57
  @logging_level = :info
57
58
  @resources_subscribe = false
58
59
  @elicitation_enabled = false
60
+ @verbose_logging = false
59
61
  @active_profile = :primary
60
62
  @profiles = default_profiles
61
63
 
62
64
  # Authentication defaults
63
65
  @authentication_methods = Rails.env.production? ? [ "jwt" ] : [ "none" ]
64
- @oauth_config = {}
66
+ @oauth_config = HashWithIndifferentAccess.new
65
67
 
66
68
  @sse_heartbeat_interval = 30
67
69
  @post_response_preference = :json
@@ -73,6 +75,7 @@ module ActionMCP
73
75
 
74
76
  # Gateway - default to ApplicationGateway if it exists, otherwise ActionMCP::Gateway
75
77
  @gateway_class = defined?(::ApplicationGateway) ? ::ApplicationGateway : ActionMCP::Gateway
78
+ @gateway_class_name = nil
76
79
 
77
80
  # Session Store
78
81
  @session_store_type = Rails.env.production? ? :active_record : :volatile
@@ -88,6 +91,15 @@ module ActionMCP
88
91
  @version || (has_rails_version ? Rails.application.version.to_s : "0.0.1")
89
92
  end
90
93
 
94
+ def gateway_class
95
+ if @gateway_class_name
96
+ klass = @gateway_class_name.constantize
97
+ klass
98
+ else
99
+ @gateway_class
100
+ end
101
+ end
102
+
91
103
  # Get active profile (considering thread-local override)
92
104
  def active_profile
93
105
  ActionMCP.thread_profiles.value || @active_profile
@@ -111,7 +123,7 @@ module ActionMCP
111
123
 
112
124
  # Extract OAuth configuration if present
113
125
  if app_config["oauth"]
114
- @oauth_config = app_config["oauth"]
126
+ @oauth_config = HashWithIndifferentAccess.new(app_config["oauth"])
115
127
  end
116
128
 
117
129
  # Extract other top-level configuration settings
@@ -121,9 +133,10 @@ module ActionMCP
121
133
  if app_config["profiles"]
122
134
  @profiles = app_config["profiles"]
123
135
  end
124
- rescue StandardError
136
+ rescue StandardError => e
125
137
  # If the config file doesn't exist in the Rails app, just use the defaults
126
- Rails.logger.debug "No MCP config found in Rails app, using defaults from gem"
138
+ Rails.logger.warn "[Configuration] Failed to load MCP config: #{e.class} - #{e.message}"
139
+ # No MCP config found in Rails app, using defaults from gem
127
140
  end
128
141
 
129
142
  # Apply the active profile
@@ -294,6 +307,16 @@ module ActionMCP
294
307
  @connects_to = app_config["connects_to"]
295
308
  end
296
309
 
310
+ # Extract verbose logging setting
311
+ if app_config.key?("verbose_logging")
312
+ @verbose_logging = app_config["verbose_logging"]
313
+ end
314
+
315
+ # Extract gateway class configuration
316
+ if app_config["gateway_class"]
317
+ @gateway_class_name = app_config["gateway_class"]
318
+ end
319
+
297
320
  # Extract session store configuration
298
321
  if app_config["session_store_type"]
299
322
  @session_store_type = app_config["session_store_type"].to_sym
@@ -61,9 +61,10 @@ module ActionMCP
61
61
  end
62
62
  end
63
63
 
64
- # Configure autoloading for the mcp/tools directory
64
+ # Configure autoloading for the mcp/tools directory and identifiers
65
65
  initializer "action_mcp.autoloading", before: :set_autoload_paths do |app|
66
66
  mcp_path = app.root.join("app/mcp")
67
+ identifiers_path = app.root.join("app/identifiers")
67
68
 
68
69
  if mcp_path.exist?
69
70
  # First add the parent mcp directory
@@ -74,6 +75,11 @@ module ActionMCP
74
75
  app.autoloaders.main.collapse(dir)
75
76
  end
76
77
  end
78
+
79
+ # Add identifiers directory for gateway identifiers
80
+ if identifiers_path.exist?
81
+ app.autoloaders.main.push_dir(identifiers_path, namespace: Object)
82
+ end
77
83
  end
78
84
 
79
85
  # Initialize the ActionMCP logger.
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ # Custom logger that filters out repetitive MCP requests
5
+ class FilteredLogger < ActiveSupport::Logger
6
+ FILTERED_PATHS = [
7
+ "/oauth/authorize",
8
+ "/.well-known/oauth-protected-resource",
9
+ "/.well-known/oauth-authorization-server"
10
+ ].freeze
11
+
12
+ FILTERED_METHODS = [
13
+ "notifications/initialized",
14
+ "notifications/ping"
15
+ ].freeze
16
+
17
+ def add(severity, message = nil, progname = nil, &block)
18
+ # Filter out repetitive OAuth metadata requests
19
+ if message && message.is_a?(String)
20
+ return if FILTERED_PATHS.any? { |path| message.include?(path) && message.include?("200 OK") }
21
+
22
+ # Filter out repetitive MCP notifications
23
+ return if FILTERED_METHODS.any? { |method| message.include?(method) }
24
+
25
+ # Filter out MCP protocol version debug messages
26
+ return if message.include?("MCP-Protocol-Version header validation passed")
27
+ end
28
+
29
+ super
30
+ end
31
+ end
32
+ end