actionmcp 0.71.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.
- checksums.yaml +4 -4
- data/app/controllers/action_mcp/application_controller.rb +2 -2
- data/lib/action_mcp/client/base.rb +3 -2
- data/lib/action_mcp/client/collection.rb +3 -3
- data/lib/action_mcp/client/streamable_http_transport.rb +56 -10
- data/lib/action_mcp/client.rb +16 -4
- data/lib/action_mcp/engine.rb +7 -1
- data/lib/action_mcp/gateway.rb +47 -133
- data/lib/action_mcp/gateway_identifier.rb +29 -0
- data/lib/action_mcp/jwt_identifier.rb +28 -0
- data/lib/action_mcp/none_identifier.rb +19 -0
- data/lib/action_mcp/o_auth_identifier.rb +34 -0
- data/lib/action_mcp/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 203f9e44a8802e007a19f41ddb89e2961fae87fb14bc0157e398dd6b80561cc3
|
4
|
+
data.tar.gz: d644c46fe4e73c1c5532d14f2e1541d511e4311479c15ef5d946aa135c4f290d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c9472a20f2aafc0c4ac4b74d8924e741b4cb94dd69154d44b060d2a75fcc01edcda857be40611c7c24b6d1c4ee5410705b776999534c35521301fef737e57d35
|
7
|
+
data.tar.gz: 78afc85939383e260a89726dbc10c516a4c7da3dded794d55545c51ed98ba2179b433d402a552c1964f15a95c9e51c0e2e6ac531f18e1e145ae4a9ebe47b893e
|
@@ -512,8 +512,8 @@ module ActionMCP
|
|
512
512
|
gateway_class = ActionMCP.configuration.gateway_class
|
513
513
|
return unless gateway_class # Skip if no gateway configured
|
514
514
|
|
515
|
-
gateway = gateway_class.new
|
516
|
-
gateway.call
|
515
|
+
gateway = gateway_class.new(request)
|
516
|
+
gateway.call
|
517
517
|
rescue ActionMCP::UnauthorizedError => e
|
518
518
|
render_unauthorized(e.message)
|
519
519
|
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:
|
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
|
|
@@ -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
|
-
|
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:
|
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
|
300
|
-
return unless @
|
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
|
-
|
306
|
-
|
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
|
|
data/lib/action_mcp/client.rb
CHANGED
@@ -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
|
-
|
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)
|
data/lib/action_mcp/engine.rb
CHANGED
@@ -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.
|
data/lib/action_mcp/gateway.rb
CHANGED
@@ -5,170 +5,84 @@ module ActionMCP
|
|
5
5
|
|
6
6
|
class Gateway
|
7
7
|
class << self
|
8
|
-
|
9
|
-
|
10
|
-
@
|
11
|
-
attr_accessor(*attrs)
|
8
|
+
# pluck in one or many GatewayIdentifier classes
|
9
|
+
def identified_by(*klasses)
|
10
|
+
@identifier_classes = klasses.flatten
|
12
11
|
end
|
13
12
|
|
14
|
-
def
|
15
|
-
@
|
13
|
+
def identifier_classes
|
14
|
+
@identifier_classes || []
|
16
15
|
end
|
17
16
|
end
|
18
17
|
|
19
|
-
|
20
|
-
|
21
|
-
attr_reader :request
|
22
|
-
|
23
|
-
def call(request)
|
18
|
+
def initialize(request)
|
24
19
|
@request = request
|
25
|
-
connect
|
26
|
-
self
|
27
20
|
end
|
28
21
|
|
29
|
-
|
22
|
+
# called by your rack/websocket layer
|
23
|
+
def call
|
30
24
|
identities = authenticate!
|
31
|
-
|
32
|
-
|
33
|
-
# Assign all identities (e.g., :user, :account)
|
34
|
-
self.class.identifiers.each do |id|
|
35
|
-
value = identities[id]
|
36
|
-
reject_unauthorized_connection unless value
|
37
|
-
|
38
|
-
public_send("#{id}=", value)
|
39
|
-
|
40
|
-
# Set to ActionMCP::Current
|
41
|
-
ActionMCP::Current.public_send("#{id}=", value)
|
42
|
-
end
|
43
|
-
|
44
|
-
# Also set the gateway instance itself
|
45
|
-
ActionMCP::Current.gateway = self
|
25
|
+
assign_identities(identities)
|
26
|
+
self
|
46
27
|
end
|
47
28
|
|
48
|
-
|
49
29
|
protected
|
50
30
|
|
51
31
|
def authenticate!
|
52
|
-
|
53
|
-
|
54
|
-
auth_methods.each do |method|
|
55
|
-
case method
|
56
|
-
when "none"
|
57
|
-
return default_user_identity
|
58
|
-
when "jwt"
|
59
|
-
result = jwt_authenticate
|
60
|
-
return result if result
|
61
|
-
when "oauth"
|
62
|
-
result = oauth_authenticate
|
63
|
-
return result if result
|
64
|
-
end
|
65
|
-
end
|
66
|
-
|
67
|
-
raise UnauthorizedError, "No valid authentication found"
|
68
|
-
end
|
69
|
-
|
70
|
-
def extract_bearer_token
|
71
|
-
header = request.headers["Authorization"] || request.headers["authorization"]
|
72
|
-
return nil unless header&.start_with?("Bearer ")
|
73
|
-
header.split(" ", 2).last
|
74
|
-
end
|
32
|
+
active_identifiers = filter_active_identifiers
|
75
33
|
|
76
|
-
|
77
|
-
|
78
|
-
user_id = payload["user_id"] || payload["sub"]
|
79
|
-
return nil unless user_id
|
80
|
-
user = User.find_by(id: user_id)
|
81
|
-
return nil unless user
|
82
|
-
|
83
|
-
# Return a hash with all identified_by attributes
|
84
|
-
self.class.identifiers.each_with_object({}) do |identifier, hash|
|
85
|
-
hash[identifier] = user if identifier == :user
|
86
|
-
# Add support for other identifiers as needed
|
34
|
+
if active_identifiers.empty?
|
35
|
+
raise ActionMCP::UnauthorizedError, "No authentication methods available"
|
87
36
|
end
|
88
|
-
end
|
89
|
-
|
90
|
-
def reject_unauthorized_connection
|
91
|
-
raise UnauthorizedError, "Unauthorized"
|
92
|
-
end
|
93
37
|
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
38
|
+
# Try identifiers in order, use the first one that succeeds
|
39
|
+
last_error = nil
|
40
|
+
active_identifiers.each do |klass|
|
41
|
+
begin
|
42
|
+
result = klass.new(@request).resolve
|
43
|
+
return { klass.identifier_name => result }
|
44
|
+
rescue ActionMCP::GatewayIdentifier::Unauthorized => e
|
45
|
+
last_error = e
|
46
|
+
# Try next identifier
|
47
|
+
next
|
101
48
|
end
|
102
|
-
# Add support for other identifiers as needed
|
103
|
-
end
|
104
|
-
end
|
105
|
-
|
106
|
-
# JWT authentication (existing implementation)
|
107
|
-
def jwt_authenticate
|
108
|
-
token = extract_bearer_token
|
109
|
-
unless token
|
110
|
-
raise UnauthorizedError, "Missing token" if ActionMCP.configuration.authentication_methods == [ "jwt" ]
|
111
|
-
return nil
|
112
49
|
end
|
113
50
|
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
return nil
|
119
|
-
end
|
120
|
-
result
|
121
|
-
rescue ActionMCP::JwtDecoder::DecodeError => e
|
122
|
-
if ActionMCP.configuration.authentication_methods == [ "jwt" ]
|
123
|
-
raise UnauthorizedError, "Invalid token"
|
124
|
-
else
|
125
|
-
nil # Let it try other authentication methods
|
126
|
-
end
|
51
|
+
# If we get here, all identifiers failed
|
52
|
+
# Use the last specific error message if available, otherwise generic message
|
53
|
+
error_message = last_error&.message || "Authentication failed"
|
54
|
+
raise ActionMCP::UnauthorizedError, error_message
|
127
55
|
end
|
128
56
|
|
129
|
-
|
130
|
-
def oauth_authenticate
|
131
|
-
return nil unless oauth_enabled?
|
132
|
-
|
133
|
-
# Check if OAuth middleware has already validated the token
|
134
|
-
token_info = request.env["action_mcp.oauth_token_info"]
|
135
|
-
return nil unless token_info && token_info["active"]
|
57
|
+
private
|
136
58
|
|
137
|
-
|
138
|
-
|
139
|
-
nil # Let it try other authentication methods
|
140
|
-
end
|
59
|
+
def filter_active_identifiers
|
60
|
+
configured_methods = ActionMCP.configuration.authentication_methods || []
|
141
61
|
|
142
|
-
|
143
|
-
|
144
|
-
ActionMCP.configuration.oauth_config.present?
|
145
|
-
end
|
62
|
+
# If no authentication methods configured, use all identifiers
|
63
|
+
return self.class.identifier_classes if configured_methods.empty?
|
146
64
|
|
147
|
-
|
148
|
-
|
65
|
+
# Normalize configured methods to strings for consistent comparison
|
66
|
+
normalized_methods = configured_methods.map(&:to_s)
|
149
67
|
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
user = User.find_by(id: user_id) || User.find_by(oauth_subject: user_id)
|
154
|
-
return nil unless user
|
155
|
-
|
156
|
-
# Return a hash with all identified_by attributes
|
157
|
-
self.class.identifiers.each_with_object({}) do |identifier, hash|
|
158
|
-
hash[identifier] = user if identifier == :user
|
159
|
-
# Add support for other identifiers as needed
|
68
|
+
# Filter identifiers to only those matching configured authentication methods
|
69
|
+
self.class.identifier_classes.select do |klass|
|
70
|
+
normalized_methods.include?(klass.auth_method.to_s)
|
160
71
|
end
|
161
72
|
end
|
162
73
|
|
163
|
-
def
|
164
|
-
|
165
|
-
|
74
|
+
def assign_identities(identities)
|
75
|
+
identities.each do |name, value|
|
76
|
+
# define accessor on the fly
|
77
|
+
self.class.attr_reader name unless respond_to?(name)
|
78
|
+
instance_variable_set("@#{name}", value)
|
166
79
|
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
end
|
80
|
+
# also set current context if you have one
|
81
|
+
ActionMCP::Current.public_send("#{name}=", value) if
|
82
|
+
ActionMCP::Current.respond_to?("#{name}=")
|
171
83
|
end
|
84
|
+
ActionMCP::Current.gateway = self if
|
85
|
+
ActionMCP::Current.respond_to?(:gateway=)
|
172
86
|
end
|
173
87
|
end
|
174
88
|
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
class GatewayIdentifier
|
5
|
+
class Unauthorized < StandardError; end
|
6
|
+
|
7
|
+
class << self
|
8
|
+
# e.g. JwtIdentifier.identifier_name => :user
|
9
|
+
attr_reader :identifier_name, :auth_method
|
10
|
+
|
11
|
+
def identifier(name)
|
12
|
+
@identifier_name = name.to_sym
|
13
|
+
end
|
14
|
+
|
15
|
+
def authenticates(method)
|
16
|
+
@auth_method = method.to_s
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def initialize(request)
|
21
|
+
@request = request
|
22
|
+
end
|
23
|
+
|
24
|
+
# must return a truthy identity object, or raise Unauthorized
|
25
|
+
def resolve
|
26
|
+
raise NotImplementedError, "#{self.class}#resolve must be implemented"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
class JwtIdentifier < GatewayIdentifier
|
5
|
+
identifier :user
|
6
|
+
authenticates :jwt
|
7
|
+
|
8
|
+
def resolve
|
9
|
+
token = extract_bearer_token
|
10
|
+
raise Unauthorized, "Missing JWT" unless token
|
11
|
+
|
12
|
+
payload = ActionMCP::JwtDecoder.decode(token)
|
13
|
+
user = User.find_by(id: payload["sub"] || payload["user_id"])
|
14
|
+
return user if user
|
15
|
+
|
16
|
+
raise Unauthorized, "Invalid JWT user"
|
17
|
+
rescue ActionMCP::JwtDecoder::DecodeError => e
|
18
|
+
raise Unauthorized, "Invalid JWT token: #{e.message}"
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def extract_bearer_token
|
24
|
+
header = @request.env["HTTP_AUTHORIZATION"] || ""
|
25
|
+
header[/\ABearer (.+)\z/, 1]
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
class NoneIdentifier < GatewayIdentifier
|
5
|
+
identifier :user
|
6
|
+
authenticates :none
|
7
|
+
|
8
|
+
def resolve
|
9
|
+
Rails.env.production? &&
|
10
|
+
raise(Unauthorized, "No auth allowed in production")
|
11
|
+
|
12
|
+
return "anonymous_user" unless defined?(User)
|
13
|
+
|
14
|
+
User.find_or_create_by!(email: "dev@localhost") do |user|
|
15
|
+
user.name = "Development User" if user.respond_to?(:name=)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
class OAuthIdentifier < GatewayIdentifier
|
5
|
+
identifier :user
|
6
|
+
authenticates :oauth
|
7
|
+
|
8
|
+
def resolve
|
9
|
+
info = @request.env["action_mcp.oauth_token_info"] or
|
10
|
+
raise Unauthorized, "Missing OAuth info"
|
11
|
+
|
12
|
+
uid = info["user_id"] || info["sub"] || info[:user_id]
|
13
|
+
raise Unauthorized, "Invalid OAuth info" unless uid
|
14
|
+
|
15
|
+
# Try to find existing user or create one for demo purposes
|
16
|
+
user = User.find_by(email: uid) ||
|
17
|
+
User.find_by(email: "#{uid}@example.com") ||
|
18
|
+
create_oauth_user(uid)
|
19
|
+
|
20
|
+
user || raise(Unauthorized, "Unable to resolve OAuth user")
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def create_oauth_user(uid)
|
26
|
+
return nil unless defined?(User)
|
27
|
+
|
28
|
+
email = uid.include?("@") ? uid : "#{uid}@example.com"
|
29
|
+
User.create!(email: email)
|
30
|
+
rescue ActiveRecord::RecordInvalid
|
31
|
+
nil
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
data/lib/action_mcp/version.rb
CHANGED
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: actionmcp
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.71.
|
4
|
+
version: 0.71.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Abdelkader Boudih
|
@@ -270,6 +270,7 @@ files:
|
|
270
270
|
- lib/action_mcp/engine.rb
|
271
271
|
- lib/action_mcp/filtered_logger.rb
|
272
272
|
- lib/action_mcp/gateway.rb
|
273
|
+
- lib/action_mcp/gateway_identifier.rb
|
273
274
|
- lib/action_mcp/gem_version.rb
|
274
275
|
- lib/action_mcp/instrumentation/controller_runtime.rb
|
275
276
|
- lib/action_mcp/instrumentation/instrumentation.rb
|
@@ -277,8 +278,11 @@ files:
|
|
277
278
|
- lib/action_mcp/integer_array.rb
|
278
279
|
- lib/action_mcp/json_rpc_handler_base.rb
|
279
280
|
- lib/action_mcp/jwt_decoder.rb
|
281
|
+
- lib/action_mcp/jwt_identifier.rb
|
280
282
|
- lib/action_mcp/log_subscriber.rb
|
281
283
|
- lib/action_mcp/logging.rb
|
284
|
+
- lib/action_mcp/none_identifier.rb
|
285
|
+
- lib/action_mcp/o_auth_identifier.rb
|
282
286
|
- lib/action_mcp/oauth.rb
|
283
287
|
- lib/action_mcp/oauth/active_record_storage.rb
|
284
288
|
- lib/action_mcp/oauth/error.rb
|