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.
- checksums.yaml +4 -4
- data/README.md +186 -15
- data/app/controllers/action_mcp/application_controller.rb +45 -38
- data/app/controllers/action_mcp/oauth/endpoints_controller.rb +11 -10
- data/app/controllers/action_mcp/oauth/metadata_controller.rb +6 -10
- data/app/controllers/action_mcp/oauth/registration_controller.rb +15 -20
- data/app/models/action_mcp/oauth_client.rb +7 -5
- data/app/models/action_mcp/oauth_token.rb +2 -1
- data/app/models/action_mcp/session.rb +40 -5
- data/config/routes.rb +4 -2
- data/db/migrate/20250512154359_consolidated_migration.rb +3 -3
- data/db/migrate/20250608112101_add_oauth_to_sessions.rb +17 -8
- data/db/migrate/20250708105124_create_action_mcp_oauth_clients.rb +7 -5
- data/db/migrate/20250708105226_create_action_mcp_oauth_tokens.rb +3 -1
- data/db/migrate/20250715070713_add_consents_to_action_mcp_sess.rb +7 -0
- data/lib/action_mcp/base_response.rb +1 -1
- data/lib/action_mcp/client/base.rb +9 -11
- data/lib/action_mcp/client/elicitation.rb +4 -4
- data/lib/action_mcp/client/json_rpc_handler.rb +11 -13
- data/lib/action_mcp/client/jwt_client_provider.rb +6 -5
- data/lib/action_mcp/client/oauth_client_provider.rb +8 -8
- data/lib/action_mcp/client/streamable_http_transport.rb +29 -39
- data/lib/action_mcp/client.rb +6 -3
- data/lib/action_mcp/configuration.rb +28 -53
- data/lib/action_mcp/engine.rb +1 -3
- data/lib/action_mcp/filtered_logger.rb +1 -1
- data/lib/action_mcp/gateway.rb +7 -11
- data/lib/action_mcp/json_rpc_handler_base.rb +0 -2
- data/lib/action_mcp/jwt_decoder.rb +4 -2
- data/lib/action_mcp/oauth/active_record_storage.rb +1 -1
- data/lib/action_mcp/oauth/memory_storage.rb +1 -3
- data/lib/action_mcp/oauth/middleware.rb +13 -18
- data/lib/action_mcp/oauth/provider.rb +45 -65
- data/lib/action_mcp/omniauth/mcp_strategy.rb +23 -37
- data/lib/action_mcp/prompt.rb +2 -0
- data/lib/action_mcp/renderable.rb +1 -1
- data/lib/action_mcp/resource_template.rb +6 -2
- data/lib/action_mcp/server/{memory_session.rb → base_session.rb} +39 -26
- data/lib/action_mcp/server/base_session_store.rb +86 -0
- data/lib/action_mcp/server/capabilities.rb +2 -1
- data/lib/action_mcp/server/elicitation.rb +3 -9
- data/lib/action_mcp/server/error_handling.rb +14 -1
- data/lib/action_mcp/server/handlers/router.rb +31 -0
- data/lib/action_mcp/server/json_rpc_handler.rb +2 -5
- data/lib/action_mcp/server/{messaging.rb → messaging_service.rb} +38 -14
- data/lib/action_mcp/server/prompts.rb +4 -4
- data/lib/action_mcp/server/resources.rb +23 -4
- data/lib/action_mcp/server/session_store_factory.rb +1 -1
- data/lib/action_mcp/server/solid_mcp_adapter.rb +9 -10
- data/lib/action_mcp/server/tools.rb +62 -43
- data/lib/action_mcp/server/transport_handler.rb +2 -4
- data/lib/action_mcp/server/volatile_session_store.rb +1 -93
- data/lib/action_mcp/tagged_stream_logging.rb +2 -2
- data/lib/action_mcp/test_helper/progress_notification_assertions.rb +4 -4
- data/lib/action_mcp/test_helper/session_store_assertions.rb +5 -1
- data/lib/action_mcp/tool.rb +48 -37
- data/lib/action_mcp/types/float_array_type.rb +5 -3
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +1 -1
- data/lib/generators/action_mcp/install/templates/application_gateway.rb +1 -0
- data/lib/tasks/action_mcp_tasks.rake +7 -5
- metadata +20 -18
- 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,
|
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
|
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,
|
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,
|
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
|
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",
|
8
|
-
|
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
|
-
|
137
|
-
|
138
|
-
|
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,
|
9
|
-
|
10
|
-
add_column :action_mcp_sessions, :
|
11
|
-
|
12
|
-
add_column :action_mcp_sessions, :
|
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,
|
16
|
-
|
17
|
-
add_index :action_mcp_sessions, :
|
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: [
|
12
|
-
t.text :response_types, array: true, default: [
|
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: [
|
17
|
-
t.json :response_types, default: [
|
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:
|
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, [
|
37
|
+
add_index :action_mcp_oauth_tokens, %i[token_type expires_at]
|
36
38
|
end
|
37
39
|
end
|
@@ -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
|
30
|
-
@session_id = options[:session_id]
|
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
|
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
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
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
|
-
|
12
|
-
|
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
|
-
|
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
|
56
|
+
when %r{^roots/}
|
57
57
|
process_roots(rpc_method, id)
|
58
|
-
when
|
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(
|
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
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
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
|
-
|
22
|
-
|
23
|
-
|
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 =
|
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: [
|
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,
|
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
|
181
|
+
event_id = line[3..].strip
|
186
182
|
elsif line.start_with?("data:")
|
187
|
-
data_lines << line[5
|
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,
|
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
|
-
|
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
|
-
|
250
|
-
|
251
|
-
|
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
|
-
|
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
|
-
|
350
|
-
|
351
|
-
|
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
|
data/lib/action_mcp/client.rb
CHANGED
@@ -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,
|
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,
|
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,
|
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)
|