actionmcp 0.71.1 → 0.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -15
  3. data/app/controllers/action_mcp/application_controller.rb +45 -38
  4. data/app/controllers/action_mcp/oauth/endpoints_controller.rb +11 -10
  5. data/app/controllers/action_mcp/oauth/metadata_controller.rb +6 -10
  6. data/app/controllers/action_mcp/oauth/registration_controller.rb +15 -20
  7. data/app/models/action_mcp/oauth_client.rb +7 -5
  8. data/app/models/action_mcp/oauth_token.rb +2 -1
  9. data/app/models/action_mcp/session.rb +40 -5
  10. data/config/routes.rb +4 -2
  11. data/db/migrate/20250512154359_consolidated_migration.rb +3 -3
  12. data/db/migrate/20250608112101_add_oauth_to_sessions.rb +17 -8
  13. data/db/migrate/20250708105124_create_action_mcp_oauth_clients.rb +7 -5
  14. data/db/migrate/20250708105226_create_action_mcp_oauth_tokens.rb +3 -1
  15. data/db/migrate/20250715070713_add_consents_to_action_mcp_sess.rb +7 -0
  16. data/lib/action_mcp/base_response.rb +1 -1
  17. data/lib/action_mcp/client/base.rb +9 -11
  18. data/lib/action_mcp/client/elicitation.rb +4 -4
  19. data/lib/action_mcp/client/json_rpc_handler.rb +11 -13
  20. data/lib/action_mcp/client/jwt_client_provider.rb +6 -5
  21. data/lib/action_mcp/client/oauth_client_provider.rb +8 -8
  22. data/lib/action_mcp/client/streamable_http_transport.rb +29 -39
  23. data/lib/action_mcp/client.rb +6 -3
  24. data/lib/action_mcp/configuration.rb +28 -53
  25. data/lib/action_mcp/engine.rb +1 -3
  26. data/lib/action_mcp/filtered_logger.rb +1 -1
  27. data/lib/action_mcp/gateway.rb +7 -11
  28. data/lib/action_mcp/json_rpc_handler_base.rb +0 -2
  29. data/lib/action_mcp/jwt_decoder.rb +4 -2
  30. data/lib/action_mcp/oauth/active_record_storage.rb +1 -1
  31. data/lib/action_mcp/oauth/memory_storage.rb +1 -3
  32. data/lib/action_mcp/oauth/middleware.rb +13 -18
  33. data/lib/action_mcp/oauth/provider.rb +45 -65
  34. data/lib/action_mcp/omniauth/mcp_strategy.rb +23 -37
  35. data/lib/action_mcp/prompt.rb +2 -0
  36. data/lib/action_mcp/renderable.rb +1 -1
  37. data/lib/action_mcp/resource_template.rb +6 -2
  38. data/lib/action_mcp/server/{memory_session.rb → base_session.rb} +39 -26
  39. data/lib/action_mcp/server/base_session_store.rb +86 -0
  40. data/lib/action_mcp/server/capabilities.rb +2 -1
  41. data/lib/action_mcp/server/elicitation.rb +3 -9
  42. data/lib/action_mcp/server/error_handling.rb +14 -1
  43. data/lib/action_mcp/server/handlers/router.rb +31 -0
  44. data/lib/action_mcp/server/json_rpc_handler.rb +2 -5
  45. data/lib/action_mcp/server/{messaging.rb → messaging_service.rb} +38 -14
  46. data/lib/action_mcp/server/prompts.rb +4 -4
  47. data/lib/action_mcp/server/resources.rb +23 -4
  48. data/lib/action_mcp/server/session_store_factory.rb +1 -1
  49. data/lib/action_mcp/server/solid_mcp_adapter.rb +9 -10
  50. data/lib/action_mcp/server/tools.rb +62 -43
  51. data/lib/action_mcp/server/transport_handler.rb +2 -4
  52. data/lib/action_mcp/server/volatile_session_store.rb +1 -93
  53. data/lib/action_mcp/tagged_stream_logging.rb +2 -2
  54. data/lib/action_mcp/test_helper/progress_notification_assertions.rb +4 -4
  55. data/lib/action_mcp/test_helper/session_store_assertions.rb +5 -1
  56. data/lib/action_mcp/tool.rb +48 -37
  57. data/lib/action_mcp/types/float_array_type.rb +5 -3
  58. data/lib/action_mcp/version.rb +1 -1
  59. data/lib/action_mcp.rb +1 -1
  60. data/lib/generators/action_mcp/install/templates/application_gateway.rb +1 -0
  61. data/lib/tasks/action_mcp_tasks.rake +7 -5
  62. metadata +20 -18
  63. data/lib/action_mcp/server/notifications.rb +0 -58
@@ -41,14 +41,17 @@ module ActionMCP
41
41
 
42
42
  # Scopes
43
43
  scope :active, -> { where(active: true) }
44
- scope :expired, -> { where("client_secret_expires_at < ?", Time.current.to_i).where.not(client_secret_expires_at: [ nil, 0 ]) }
44
+ scope :expired, lambda {
45
+ where("client_secret_expires_at < ?", Time.current.to_i).where.not(client_secret_expires_at: [ nil, 0 ])
46
+ }
45
47
 
46
48
  # Callbacks
47
49
  before_create :set_issued_at
48
50
 
49
51
  # Check if client secret is expired
50
52
  def secret_expired?
51
- return false if client_secret_expires_at.nil? || client_secret_expires_at == 0
53
+ return false if client_secret_expires_at.nil? || client_secret_expires_at.zero?
54
+
52
55
  Time.current.to_i > client_secret_expires_at
53
56
  end
54
57
 
@@ -65,6 +68,7 @@ module ActionMCP
65
68
  # Validate redirect URI against registered URIs
66
69
  def valid_redirect_uri?(uri)
67
70
  return false if redirect_uris.blank?
71
+
68
72
  redirect_uris.include?(uri)
69
73
  end
70
74
 
@@ -133,9 +137,7 @@ module ActionMCP
133
137
  end
134
138
 
135
139
  # Generate client secret for confidential clients
136
- if client.confidential_client?
137
- client.client_secret = SecureRandom.urlsafe_base64(32)
138
- end
140
+ client.client_secret = SecureRandom.urlsafe_base64(32) if client.confidential_client?
139
141
 
140
142
  # Store any additional metadata
141
143
  known_fields = %w[
@@ -90,7 +90,8 @@ module ActionMCP
90
90
  end
91
91
 
92
92
  # Create authorization code
93
- def self.create_authorization_code(client_id:, user_id:, redirect_uri:, scope:, code_challenge: nil, code_challenge_method: nil)
93
+ def self.create_authorization_code(client_id:, user_id:, redirect_uri:, scope:, code_challenge: nil,
94
+ code_challenge_method: nil)
94
95
  create!(
95
96
  token: SecureRandom.urlsafe_base64(32),
96
97
  token_type: AUTHORIZATION_CODE,
@@ -8,6 +8,7 @@
8
8
  # authentication_method :string default("none")
9
9
  # client_capabilities :json
10
10
  # client_info :json
11
+ # consents :json not null
11
12
  # ended_at :datetime
12
13
  # initialized :boolean default(FALSE), not null
13
14
  # messages_count :integer default(0), not null
@@ -40,6 +41,10 @@ module ActionMCP
40
41
  # such as client and server capabilities, protocol version, and session status.
41
42
  # It also manages the association with messages and subscriptions related to the session.
42
43
  class Session < ApplicationRecord
44
+ after_initialize do
45
+ self.consents = {} if consents == "{}" || consents.nil?
46
+ end
47
+
43
48
  include MCPConsoleHelpers
44
49
  attribute :id, :string, default: -> { SecureRandom.hex(6) }
45
50
  has_many :messages,
@@ -180,9 +185,7 @@ module ActionMCP
180
185
  # Maintain cache limit by removing oldest events if needed
181
186
  count = sse_events.count
182
187
  excess = count - max_events
183
- if excess.positive?
184
- sse_events.order(event_id: :asc).limit(excess).delete_all
185
- end
188
+ sse_events.order(event_id: :asc).limit(excess).delete_all if excess.positive?
186
189
 
187
190
  event
188
191
  end
@@ -363,7 +366,7 @@ module ActionMCP
363
366
  # Required by MCP 2025-03-26 specification for session binding
364
367
 
365
368
  # Store OAuth token and user context in session
366
- def store_oauth_token(access_token:, refresh_token: nil, expires_at:, user_context: {})
369
+ def store_oauth_token(access_token:, expires_at:, refresh_token: nil, user_context: {})
367
370
  update!(
368
371
  oauth_access_token: access_token,
369
372
  oauth_refresh_token: refresh_token,
@@ -406,7 +409,7 @@ module ActionMCP
406
409
  end
407
410
 
408
411
  # Update OAuth token (for refresh flow)
409
- def update_oauth_token(access_token:, refresh_token: nil, expires_at:)
412
+ def update_oauth_token(access_token:, expires_at:, refresh_token: nil)
410
413
  update!(
411
414
  oauth_access_token: access_token,
412
415
  oauth_refresh_token: refresh_token,
@@ -447,6 +450,38 @@ module ActionMCP
447
450
  )
448
451
  end
449
452
 
453
+ # Consent management methods as per MCP specification
454
+ # These methods manage user consents for tools and resources
455
+
456
+ # Checks if consent has been granted for a specific key
457
+ # @param key [String] The consent key (e.g., tool name or resource URI)
458
+ # @return [Boolean] true if consent is granted, false otherwise
459
+ def consent_granted_for?(key)
460
+ consents_hash = consents.is_a?(String) ? JSON.parse(consents) : consents
461
+ consents_hash&.key?(key) && consents_hash[key] == true
462
+ end
463
+
464
+ # Grants consent for a specific key
465
+ # @param key [String] The consent key to grant
466
+ # @return [Boolean] true if saved successfully
467
+ def grant_consent(key)
468
+ self.consents = JSON.parse(consents) if consents.is_a?(String)
469
+ self.consents ||= {}
470
+ self.consents[key] = true
471
+ save!
472
+ end
473
+
474
+ # Revokes consent for a specific key
475
+ # @param key [String] The consent key to revoke
476
+ # @return [void]
477
+ def revoke_consent(key)
478
+ self.consents = JSON.parse(self.consents) if self.consents.is_a?(String)
479
+ return unless consents&.key?(key)
480
+
481
+ consents.delete(key)
482
+ save!
483
+ end
484
+
450
485
  private
451
486
 
452
487
  # if this session is from a server, the writer is the client
data/config/routes.rb CHANGED
@@ -4,8 +4,10 @@ ActionMCP::Engine.routes.draw do
4
4
  get "/up", to: "/rails/health#show", as: :action_mcp_health_check
5
5
 
6
6
  # OAuth 2.1 metadata endpoints
7
- get "/.well-known/oauth-authorization-server", to: "oauth/metadata#authorization_server", as: :oauth_authorization_server_metadata
8
- get "/.well-known/oauth-protected-resource", to: "oauth/metadata#protected_resource", as: :oauth_protected_resource_metadata
7
+ get "/.well-known/oauth-authorization-server", to: "oauth/metadata#authorization_server",
8
+ as: :oauth_authorization_server_metadata
9
+ get "/.well-known/oauth-protected-resource", to: "oauth/metadata#protected_resource",
10
+ as: :oauth_protected_resource_metadata
9
11
 
10
12
  # OAuth 2.1 endpoints
11
13
  get "/oauth/authorize", to: "oauth/endpoints#authorize", as: :oauth_authorize
@@ -133,9 +133,9 @@ class ConsolidatedMigration < ActiveRecord::Migration[8.0]
133
133
  return unless column_exists?(:action_mcp_session_messages, :direction)
134
134
 
135
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
+ return unless connection.adapter_name.downcase != 'sqlite'
137
+
138
+ change_column_comment :action_mcp_session_messages, :direction, 'The message recipient'
139
139
  end
140
140
 
141
141
  private
@@ -5,15 +5,24 @@ class AddOAuthToSessions < ActiveRecord::Migration[8.0]
5
5
  # Use json for all databases (PostgreSQL, SQLite3, MySQL) for consistency
6
6
  json_type = :json
7
7
 
8
- add_column :action_mcp_sessions, :oauth_access_token, :string unless column_exists?(:action_mcp_sessions, :oauth_access_token)
9
- add_column :action_mcp_sessions, :oauth_refresh_token, :string unless column_exists?(:action_mcp_sessions, :oauth_refresh_token)
10
- add_column :action_mcp_sessions, :oauth_token_expires_at, :datetime unless column_exists?(:action_mcp_sessions, :oauth_token_expires_at)
11
- add_column :action_mcp_sessions, :oauth_user_context, json_type unless column_exists?(:action_mcp_sessions, :oauth_user_context)
12
- add_column :action_mcp_sessions, :authentication_method, :string, default: "none" unless column_exists?(:action_mcp_sessions, :authentication_method)
8
+ add_column :action_mcp_sessions, :oauth_access_token, :string unless column_exists?(:action_mcp_sessions,
9
+ :oauth_access_token)
10
+ add_column :action_mcp_sessions, :oauth_refresh_token, :string unless column_exists?(:action_mcp_sessions,
11
+ :oauth_refresh_token)
12
+ add_column :action_mcp_sessions, :oauth_token_expires_at, :datetime unless column_exists?(:action_mcp_sessions,
13
+ :oauth_token_expires_at)
14
+ add_column :action_mcp_sessions, :oauth_user_context, json_type unless column_exists?(:action_mcp_sessions,
15
+ :oauth_user_context)
16
+ add_column :action_mcp_sessions, :authentication_method, :string, default: 'none' unless column_exists?(
17
+ :action_mcp_sessions, :authentication_method
18
+ )
13
19
 
14
20
  # Add indexes for performance
15
- add_index :action_mcp_sessions, :oauth_access_token, unique: true unless index_exists?(:action_mcp_sessions, :oauth_access_token)
16
- add_index :action_mcp_sessions, :oauth_token_expires_at unless index_exists?(:action_mcp_sessions, :oauth_token_expires_at)
17
- add_index :action_mcp_sessions, :authentication_method unless index_exists?(:action_mcp_sessions, :authentication_method)
21
+ add_index :action_mcp_sessions, :oauth_access_token, unique: true unless index_exists?(:action_mcp_sessions,
22
+ :oauth_access_token)
23
+ add_index :action_mcp_sessions, :oauth_token_expires_at unless index_exists?(:action_mcp_sessions,
24
+ :oauth_token_expires_at)
25
+ add_index :action_mcp_sessions, :authentication_method unless index_exists?(:action_mcp_sessions,
26
+ :authentication_method)
18
27
  end
19
28
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class CreateActionMCPOAuthClients < ActiveRecord::Migration[7.2]
2
4
  def change
3
5
  create_table :action_mcp_oauth_clients do |t|
@@ -8,16 +10,16 @@ class CreateActionMCPOAuthClients < ActiveRecord::Migration[7.2]
8
10
  # Store arrays as JSON for database compatibility
9
11
  if connection.adapter_name.downcase.include?('postgresql')
10
12
  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
+ t.text :grant_types, array: true, default: [ 'authorization_code' ]
14
+ t.text :response_types, array: true, default: [ 'code' ]
13
15
  else
14
16
  # For SQLite and other databases, use JSON
15
17
  t.json :redirect_uris, default: []
16
- t.json :grant_types, default: [ "authorization_code" ]
17
- t.json :response_types, default: [ "code" ]
18
+ t.json :grant_types, default: [ 'authorization_code' ]
19
+ t.json :response_types, default: [ 'code' ]
18
20
  end
19
21
 
20
- t.string :token_endpoint_auth_method, default: "client_secret_basic"
22
+ t.string :token_endpoint_auth_method, default: 'client_secret_basic'
21
23
  t.text :scope
22
24
  t.boolean :active, default: true
23
25
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class CreateActionMCPOAuthTokens < ActiveRecord::Migration[7.2]
2
4
  def change
3
5
  create_table :action_mcp_oauth_tokens do |t|
@@ -32,6 +34,6 @@ class CreateActionMCPOAuthTokens < ActiveRecord::Migration[7.2]
32
34
  add_index :action_mcp_oauth_tokens, :user_id
33
35
  add_index :action_mcp_oauth_tokens, :expires_at
34
36
  add_index :action_mcp_oauth_tokens, :revoked
35
- add_index :action_mcp_oauth_tokens, [ :token_type, :expires_at ]
37
+ add_index :action_mcp_oauth_tokens, %i[token_type expires_at]
36
38
  end
37
39
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddConsentsToActionMCPSess < ActiveRecord::Migration[8.0]
4
+ def change
5
+ add_column :action_mcp_sessions, :consents, :json, default: {}, null: false
6
+ end
7
+ end
@@ -19,7 +19,7 @@ module ActionMCP
19
19
  end
20
20
 
21
21
  # Convert to hash format expected by MCP protocol
22
- def to_h(options = nil)
22
+ def to_h(_options = nil)
23
23
  if @is_error
24
24
  JSON_RPC::JsonRpcError.new(@symbol, message: @error_message, data: @error_data).to_h
25
25
  else
@@ -26,8 +26,8 @@ module ActionMCP
26
26
  def initialize(transport:, logger: ActionMCP.logger, protocol_version: nil, **options)
27
27
  @logger = logger
28
28
  @transport = transport
29
- @session = nil # Session will be created/loaded based on server response
30
- @session_id = options[:session_id] # Optional session ID for resumption
29
+ @session = nil # Session will be created/loaded based on server response
30
+ @session_id = options[:session_id] # Optional session ID for resumption
31
31
  @protocol_version = protocol_version || ActionMCP::DEFAULT_PROTOCOL_VERSION
32
32
  @server_capabilities = nil
33
33
  @connection_error = nil
@@ -91,7 +91,7 @@ module ActionMCP
91
91
 
92
92
  begin
93
93
  # Only write to session if it exists (after initialization)
94
- session.write(payload) if session
94
+ session&.write(payload)
95
95
  data = payload.to_json unless payload.is_a?(String)
96
96
  @transport.send_message(data)
97
97
  true
@@ -109,11 +109,11 @@ module ActionMCP
109
109
  end
110
110
 
111
111
  # Only update session if it exists
112
- if @session
113
- @session.server_capabilities = server.capabilities
114
- @session.server_info = server.server_info
115
- @session.save
116
- end
112
+ return unless @session
113
+
114
+ @session.server_capabilities = server.capabilities
115
+ @session.server_info = server.server_info
116
+ @session.save
117
117
  end
118
118
 
119
119
  def initialized?
@@ -176,9 +176,7 @@ module ActionMCP
176
176
  log_debug("Sending client capabilities")
177
177
 
178
178
  # If we have a session_id, we're trying to resume
179
- if @session_id
180
- log_debug("Attempting to resume session: #{@session_id}")
181
- end
179
+ log_debug("Attempting to resume session: #{@session_id}") if @session_id
182
180
 
183
181
  params = {
184
182
  protocolVersion: @protocol_version,
@@ -8,15 +8,15 @@ module ActionMCP
8
8
  # @param id [String, Integer] The request ID
9
9
  # @param params [Hash] The elicitation parameters
10
10
  def process_elicitation_request(id, params)
11
- message = params["message"]
12
- requested_schema = params["requestedSchema"]
11
+ params["message"]
12
+ params["requestedSchema"]
13
13
 
14
14
  # In a real implementation, this would prompt the user
15
15
  # For now, we'll just return a decline response
16
16
  # Actual implementations should override this method
17
17
  send_jsonrpc_response(id, result: {
18
- action: "decline"
19
- })
18
+ action: "decline"
19
+ })
20
20
  end
21
21
 
22
22
  # Send elicitation response
@@ -53,15 +53,13 @@ module ActionMCP
53
53
  case rpc_method
54
54
  when Methods::ELICITATION_CREATE
55
55
  client.process_elicitation_request(id, params)
56
- when /^roots\//
56
+ when %r{^roots/}
57
57
  process_roots(rpc_method, id)
58
- when /^sampling\//
58
+ when %r{^sampling/}
59
59
  process_sampling(rpc_method, id, params)
60
60
  else
61
61
  common_result = handle_common_methods(rpc_method, id, params)
62
- if common_result.nil?
63
- client.log_warn("Unknown server method: #{rpc_method} #{id} #{params}")
64
- end
62
+ client.log_warn("Unknown server method: #{rpc_method} #{id} #{params}") if common_result.nil?
65
63
  end
66
64
  end
67
65
 
@@ -161,7 +159,7 @@ module ActionMCP
161
159
  client.log_error("Unknown error: #{id} #{error}")
162
160
  end
163
161
 
164
- def handle_initialize_response(request_id, result)
162
+ def handle_initialize_response(_request_id, result)
165
163
  # Session ID comes from HTTP headers, not the response body
166
164
  # The transport should have already extracted it
167
165
  session_id = transport.instance_variable_get(:@session_id)
@@ -179,13 +177,13 @@ module ActionMCP
179
177
  else
180
178
  # Create a new session with the server-provided ID
181
179
  client.instance_variable_set(:@session, ActionMCP::Session.from_client.new(
182
- id: session_id,
183
- protocol_version: result["protocolVersion"] || ActionMCP::DEFAULT_PROTOCOL_VERSION,
184
- client_info: client.client_info,
185
- client_capabilities: client.client_capabilities,
186
- server_info: result["serverInfo"],
187
- server_capabilities: result["capabilities"]
188
- ))
180
+ id: session_id,
181
+ protocol_version: result["protocolVersion"] || ActionMCP::DEFAULT_PROTOCOL_VERSION,
182
+ client_info: client.client_info,
183
+ client_capabilities: client.client_capabilities,
184
+ server_info: result["serverInfo"],
185
+ server_capabilities: result["capabilities"]
186
+ ))
189
187
  client.session.save
190
188
  client.log_info("Created new session: #{session_id}")
191
189
  end
@@ -18,9 +18,9 @@ module ActionMCP
18
18
  @logger = logger
19
19
 
20
20
  # If token provided during initialization, store it
21
- if token
22
- save_token(token)
23
- end
21
+ return unless token
22
+
23
+ save_token(token)
24
24
  end
25
25
 
26
26
  # Check if client has valid authentication
@@ -62,6 +62,7 @@ module ActionMCP
62
62
  token = current_token
63
63
  return nil unless token
64
64
  return nil if token_expired?(token)
65
+
65
66
  token
66
67
  end
67
68
 
@@ -85,7 +86,7 @@ module ActionMCP
85
86
 
86
87
  # Add 30 second buffer for clock skew
87
88
  Time.at(exp) <= Time.now + 30
88
- rescue => e
89
+ rescue StandardError => e
89
90
  log_debug("Error checking token expiration: #{e.message}")
90
91
  true # Treat invalid tokens as expired
91
92
  end
@@ -103,7 +104,7 @@ module ActionMCP
103
104
 
104
105
  payload_json = Base64.urlsafe_decode64(payload_base64)
105
106
  JSON.parse(payload_json)
106
- rescue => e
107
+ rescue StandardError => e
107
108
  raise AuthenticationError, "Failed to decode JWT: #{e.message}"
108
109
  end
109
110
 
@@ -173,16 +173,18 @@ module ActionMCP
173
173
  well_known_url.path = "/.well-known/oauth-authorization-server"
174
174
 
175
175
  response = @http_client.get(well_known_url)
176
- unless response.success?
177
- raise AuthenticationError, "Failed to fetch server metadata: #{response.status}"
178
- end
176
+ raise AuthenticationError, "Failed to fetch server metadata: #{response.status}" unless response.success?
179
177
 
180
178
  JSON.parse(response.body, symbolize_names: true)
181
179
  end
182
180
 
183
181
  def handle_token_response(response)
184
182
  unless response.success?
185
- error_body = JSON.parse(response.body) rescue {}
183
+ error_body = begin
184
+ JSON.parse(response.body)
185
+ rescue StandardError
186
+ {}
187
+ end
186
188
  error_msg = error_body["error_description"] || error_body["error"] || "Token request failed"
187
189
  raise AuthenticationError, "#{error_msg} (#{response.status})"
188
190
  end
@@ -190,9 +192,7 @@ module ActionMCP
190
192
  token_data = JSON.parse(response.body, symbolize_names: true)
191
193
 
192
194
  # 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
195
+ token_data[:expires_at] = Time.now.to_i + token_data[:expires_in].to_i if token_data[:expires_in]
196
196
 
197
197
  save_tokens(token_data)
198
198
  log_debug("OAuth tokens obtained successfully")
@@ -219,7 +219,7 @@ module ActionMCP
219
219
  client_name: "ActionMCP Client",
220
220
  client_uri: "https://github.com/anthropics/action_mcp",
221
221
  redirect_uris: [ @redirect_url.to_s ],
222
- grant_types: [ "authorization_code", "refresh_token" ],
222
+ grant_types: %w[authorization_code refresh_token],
223
223
  response_types: [ "code" ],
224
224
  token_endpoint_auth_method: "none", # Public client
225
225
  code_challenge_methods_supported: [ "S256" ]
@@ -15,7 +15,8 @@ module ActionMCP
15
15
 
16
16
  attr_reader :session_id, :last_event_id, :protocol_version
17
17
 
18
- def initialize(url, session_store:, session_id: nil, oauth_provider: nil, jwt_provider: nil, protocol_version: nil, **options)
18
+ def initialize(url, session_store:, session_id: nil, oauth_provider: nil, jwt_provider: nil,
19
+ protocol_version: nil, **options)
19
20
  super(url, session_store: session_store, **options)
20
21
  @session_id = session_id
21
22
  @oauth_provider = oauth_provider
@@ -66,9 +67,7 @@ module ActionMCP
66
67
  end
67
68
 
68
69
  def send_message(message)
69
- unless ready?
70
- raise ConnectionError, "Transport not ready"
71
- end
70
+ raise ConnectionError, "Transport not ready" unless ready?
72
71
 
73
72
  headers = build_post_headers
74
73
  json_data = message.is_a?(String) ? message : message.to_json
@@ -104,9 +103,7 @@ module ActionMCP
104
103
  headers["Last-Event-ID"] = @last_event_id if @last_event_id
105
104
 
106
105
  # 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
106
+ headers["MCP-Protocol-Version"] = @negotiated_protocol_version if @negotiated_protocol_version
110
107
 
111
108
  headers.merge!(oauth_headers)
112
109
  headers.merge!(jwt_headers)
@@ -123,9 +120,7 @@ module ActionMCP
123
120
 
124
121
  # Add MCP-Protocol-Version header as per 2025-06-18 spec
125
122
  # 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
123
+ headers["MCP-Protocol-Version"] = @negotiated_protocol_version if @negotiated_protocol_version
129
124
 
130
125
  headers.merge!(oauth_headers)
131
126
  headers.merge!(jwt_headers)
@@ -154,6 +149,7 @@ module ActionMCP
154
149
  @http_client.get(@url, nil, headers) do |req|
155
150
  req.options.on_data = proc do |chunk, _bytes|
156
151
  break if @stop_requested
152
+
157
153
  process_sse_chunk(chunk)
158
154
  end
159
155
  end
@@ -182,9 +178,9 @@ module ActionMCP
182
178
 
183
179
  lines.each do |line|
184
180
  if line.start_with?("id:")
185
- event_id = line[3..-1].strip
181
+ event_id = line[3..].strip
186
182
  elsif line.start_with?("data:")
187
- data_lines << line[5..-1].strip
183
+ data_lines << line[5..].strip
188
184
  end
189
185
  end
190
186
 
@@ -201,11 +197,9 @@ module ActionMCP
201
197
  end
202
198
  end
203
199
 
204
- def handle_post_response(response, original_message)
200
+ def handle_post_response(response, _original_message)
205
201
  # Extract session ID from response headers
206
- if response.headers["mcp-session-id"]
207
- @session_id = response.headers["mcp-session-id"]
208
- end
202
+ @session_id = response.headers["mcp-session-id"] if response.headers["mcp-session-id"]
209
203
 
210
204
  case response.status
211
205
  when 200
@@ -237,19 +231,17 @@ module ActionMCP
237
231
  end
238
232
 
239
233
  def handle_json_response(response)
240
- begin
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
234
+ message = MultiJson.load(response.body)
248
235
 
249
- handle_message(message)
250
- rescue MultiJson::ParseError => e
251
- log_error("Failed to parse JSON response: #{e}")
236
+ # Check if this is an initialize response to capture negotiated protocol version
237
+ if message.is_a?(Hash) && message["result"] && message["result"]["protocolVersion"]
238
+ @negotiated_protocol_version = message["result"]["protocolVersion"]
239
+ log_debug("Negotiated protocol version: #{@negotiated_protocol_version}")
252
240
  end
241
+
242
+ handle_message(message)
243
+ rescue MultiJson::ParseError => e
244
+ log_error("Failed to parse JSON response: #{e}")
253
245
  end
254
246
 
255
247
  def handle_sse_response_stream(response)
@@ -261,9 +253,7 @@ module ActionMCP
261
253
 
262
254
  def handle_error_response(response)
263
255
  error_msg = +"HTTP #{response.status}: #{response.reason_phrase}"
264
- if response.body && !response.body.empty?
265
- error_msg << " - #{response.body}"
266
- end
256
+ error_msg << " - #{response.body}" if response.body && !response.body.empty?
267
257
  raise ConnectionError, error_msg
268
258
  end
269
259
 
@@ -340,17 +330,17 @@ module ActionMCP
340
330
  def handle_authentication_error(response)
341
331
  # Check for OAuth challenge in WWW-Authenticate header
342
332
  www_auth = response.headers["www-authenticate"]
343
- if www_auth&.include?("Bearer")
344
- if @oauth_provider
345
- log_debug("Received OAuth challenge, clearing OAuth tokens")
346
- @oauth_provider.clear_tokens!
347
- end
333
+ return unless www_auth&.include?("Bearer")
348
334
 
349
- if @jwt_provider
350
- log_debug("Received Bearer challenge, clearing JWT tokens")
351
- @jwt_provider.clear_tokens!
352
- end
335
+ if @oauth_provider
336
+ log_debug("Received OAuth challenge, clearing OAuth tokens")
337
+ @oauth_provider.clear_tokens!
353
338
  end
339
+
340
+ return unless @jwt_provider
341
+
342
+ log_debug("Received Bearer challenge, clearing JWT tokens")
343
+ @jwt_provider.clear_tokens!
354
344
  end
355
345
 
356
346
  def user_agent
@@ -58,7 +58,8 @@ module ActionMCP
58
58
  # "http://127.0.0.1:3001/action_mcp",
59
59
  # jwt_provider: jwt_provider
60
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)
61
+ def self.create_client(endpoint, transport: :streamable_http, session_store: nil, session_id: nil,
62
+ oauth_provider: nil, jwt_provider: nil, protocol_version: nil, logger: Logger.new($stdout), **options)
62
63
  unless endpoint =~ %r{\Ahttps?://}
63
64
  raise ArgumentError, "Only HTTP(S) endpoints are supported. STDIO and other transports are not supported."
64
65
  end
@@ -67,11 +68,13 @@ module ActionMCP
67
68
  store = Client::SessionStoreFactory.create(session_store, **options)
68
69
 
69
70
  # Create transport
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)
71
+ transport_instance = create_transport(transport, endpoint, session_store: store, session_id: session_id,
72
+ oauth_provider: oauth_provider, jwt_provider: jwt_provider, protocol_version: protocol_version, logger: logger, **options)
71
73
 
72
74
  logger.info("Creating #{transport} client for endpoint: #{endpoint}")
73
75
  # 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)
76
+ Client::Base.new(transport: transport_instance, logger: logger, session_id: session_id,
77
+ protocol_version: protocol_version, **options)
75
78
  end
76
79
 
77
80
  private_class_method def self.create_transport(type, endpoint, **options)