actionmcp 0.50.13 → 0.52.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +83 -0
  3. data/app/controllers/action_mcp/application_controller.rb +12 -6
  4. data/app/models/action_mcp/session.rb +5 -1
  5. data/lib/action_mcp/client/base.rb +88 -82
  6. data/lib/action_mcp/client/json_rpc_handler.rb +42 -5
  7. data/lib/action_mcp/client/session_store.rb +231 -0
  8. data/lib/action_mcp/client/streamable_http_transport.rb +291 -0
  9. data/lib/action_mcp/client/transport.rb +107 -0
  10. data/lib/action_mcp/client.rb +45 -5
  11. data/lib/action_mcp/configuration.rb +16 -1
  12. data/lib/action_mcp/current.rb +19 -0
  13. data/lib/action_mcp/current_helpers.rb +19 -0
  14. data/lib/action_mcp/gateway.rb +85 -0
  15. data/lib/action_mcp/json_rpc_handler_base.rb +6 -1
  16. data/lib/action_mcp/jwt_decoder.rb +26 -0
  17. data/lib/action_mcp/prompt.rb +1 -0
  18. data/lib/action_mcp/resource_template.rb +1 -0
  19. data/lib/action_mcp/server/base_messaging.rb +14 -0
  20. data/lib/action_mcp/server/capabilities.rb +23 -0
  21. data/lib/action_mcp/server/error_aware.rb +8 -1
  22. data/lib/action_mcp/server/handlers/tool_handler.rb +2 -1
  23. data/lib/action_mcp/server/json_rpc_handler.rb +12 -4
  24. data/lib/action_mcp/server/messaging.rb +12 -1
  25. data/lib/action_mcp/server/registry_management.rb +0 -1
  26. data/lib/action_mcp/server/response_collector.rb +40 -0
  27. data/lib/action_mcp/server/session_store.rb +762 -0
  28. data/lib/action_mcp/server/tools.rb +14 -3
  29. data/lib/action_mcp/server/transport_handler.rb +9 -5
  30. data/lib/action_mcp/server.rb +7 -0
  31. data/lib/action_mcp/tagged_stream_logging.rb +0 -4
  32. data/lib/action_mcp/test_helper/progress_notification_assertions.rb +105 -0
  33. data/lib/action_mcp/test_helper/session_store_assertions.rb +130 -0
  34. data/lib/action_mcp/test_helper.rb +4 -0
  35. data/lib/action_mcp/tool.rb +1 -0
  36. data/lib/action_mcp/version.rb +1 -1
  37. data/lib/action_mcp.rb +0 -1
  38. data/lib/generators/action_mcp/install/install_generator.rb +4 -0
  39. data/lib/generators/action_mcp/install/templates/application_gateway.rb +40 -0
  40. metadata +30 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a8cbc049b43ac92baf337a6700c1a31f3dc7a9a5bc6c63a245d845b26f40a6a4
4
- data.tar.gz: e116ae134611daf77d0db8955325c4f3318ab27b7bec45ce77b1f4bdebdbc413
3
+ metadata.gz: 123d51b56f85e45cb622885fb6bfcf6caf6d310292302a6ad4a9886e84c36af2
4
+ data.tar.gz: 15eb5bd01e1985a4eac9c6d83b463d0f939c02ccd330f72366fa0d08603c6f2b
5
5
  SHA512:
6
- metadata.gz: 0ca8d760b8758d6325da76b10ef9b94e76312538c1a0d4765a793944746f3f4b83bed4a843b9e0de6a5c10a4878e2cece3157386a056750fc9fe6d57543054b5
7
- data.tar.gz: '0692acfc6b64ee7c3bb7047cc863dd2976e7aca7bb6c19e39a146ced044e1136c71364598f02b5cff9a3af803114a647692005150d758957c0db427366bed185'
6
+ metadata.gz: 724a4dc93cc887ce3dc735fe6a00b375a36012887f2aa081b01a45b314460a297ebd3b17cfa423a55276462cca5736feea816a8691fcc0b6006bfb7a385b0f9b
7
+ data.tar.gz: 451af9c7919a5e65d80af1f3cc3b36e666148bc4b1351049a7ae33e01fd26daaad13b5d72b6a32476e9709c8b44a0291c2ad304f25d9607c99082bf59d99db02
data/README.md CHANGED
@@ -380,6 +380,89 @@ This will create `config/mcp.yml` with example configurations for all environmen
380
380
 
381
381
  > **Note:** Authentication and authorization are not included. You are responsible for securing the endpoint.
382
382
 
383
+ ## Authentication with Gateway
384
+
385
+ ActionMCP provides a Gateway system similar to ActionCable's Connection for handling authentication. The Gateway allows you to authenticate users and make them available throughout your MCP components.
386
+
387
+ ### Creating an ApplicationGateway
388
+
389
+ When you run the install generator, it creates an `ApplicationGateway` class:
390
+
391
+ ```ruby
392
+ # app/mcp/application_gateway.rb
393
+ class ApplicationGateway < ActionMCP::Gateway
394
+ # Specify what attributes identify a connection
395
+ identified_by :user
396
+
397
+ protected
398
+
399
+ def authenticate!
400
+ token = extract_bearer_token
401
+ raise ActionMCP::UnauthorizedError, "Missing token" unless token
402
+
403
+ payload = ActionMCP::JwtDecoder.decode(token)
404
+ user = resolve_user(payload)
405
+
406
+ raise ActionMCP::UnauthorizedError, "Unauthorized" unless user
407
+
408
+ # Return a hash with all identified_by attributes
409
+ { user: user }
410
+ end
411
+
412
+ private
413
+
414
+ def resolve_user(payload)
415
+ user_id = payload["user_id"] || payload["sub"]
416
+ User.find_by(id: user_id) if user_id
417
+ end
418
+ end
419
+ ```
420
+
421
+ ### Using Multiple Identifiers
422
+
423
+ You can identify connections by multiple attributes:
424
+
425
+ ```ruby
426
+ class ApplicationGateway < ActionMCP::Gateway
427
+ identified_by :user, :organization
428
+
429
+ protected
430
+
431
+ def authenticate!
432
+ # ... authentication logic ...
433
+
434
+ {
435
+ user: user,
436
+ organization: user.organization
437
+ }
438
+ end
439
+ end
440
+ ```
441
+
442
+ ### Accessing Current User in Components
443
+
444
+ Once authenticated, the current user (and other identifiers) are available in your tools, prompts, and resource templates:
445
+
446
+ ```ruby
447
+ class MyTool < ApplicationMCPTool
448
+ def perform
449
+ # Access the authenticated user
450
+ if current_user
451
+ render text: "Hello, #{current_user.name}!"
452
+ else
453
+ render text: "Hi Stranger! It's been a while "
454
+ end
455
+ end
456
+ end
457
+ ```
458
+
459
+ ### Current Attributes
460
+
461
+ ActionMCP uses Rails' CurrentAttributes to store the authenticated context. The `ActionMCP::Current` class provides:
462
+ - `ActionMCP::Current.user` - The authenticated user
463
+ - `ActionMCP::Current.gateway` - The gateway instance
464
+ - Any other attributes you define with `identified_by`
465
+
383
466
  ### 1. Create `mcp.ru`
384
467
 
385
468
  ```ruby
@@ -158,11 +158,11 @@ module ActionMCP
158
158
  response.headers[MCP_SESSION_ID_HEADER] = session.id
159
159
  end
160
160
 
161
- transport_handler = Server::TransportHandler.new(session)
161
+ # Use return mode for the transport handler when we need to capture responses
162
+ transport_handler = Server::TransportHandler.new(session, messaging_mode: :return)
162
163
  json_rpc_handler = Server::JsonRpcHandler.new(transport_handler)
163
164
 
164
165
  result = json_rpc_handler.call(jsonrpc_params)
165
-
166
166
  process_handler_results(result, session, session_initially_missing, is_initialize_request)
167
167
  rescue ActionController::Live::ClientDisconnected, IOError => e
168
168
  Rails.logger.debug "Unified SSE (POST): Client disconnected during response: #{e.message}"
@@ -182,7 +182,7 @@ module ActionMCP
182
182
  session_id_from_header = extract_session_id
183
183
  return render_bad_request("Mcp-Session-Id header is required for DELETE requests.") unless session_id_from_header
184
184
 
185
- session = Session.find_by(id: session_id_from_header)
185
+ session = Server.session_store.load_session(session_id_from_header)
186
186
  if session.nil?
187
187
  return render_not_found("Session not found.")
188
188
  elsif session.status == "closed"
@@ -206,7 +206,7 @@ module ActionMCP
206
206
  def find_or_initialize_session
207
207
  session_id = extract_session_id
208
208
  if session_id
209
- session = Session.find_by(id: session_id)
209
+ session = Server.session_store.load_session(session_id)
210
210
  if session
211
211
  if ActionMCP.configuration.vibed_ignore_version
212
212
  if session.protocol_version != self.class::REQUIRED_PROTOCOL_VERSION
@@ -218,7 +218,7 @@ module ActionMCP
218
218
  end
219
219
  session
220
220
  else
221
- Session.new(protocol_version: self.class::REQUIRED_PROTOCOL_VERSION)
221
+ Server.session_store.create_session(nil, protocol_version: self.class::REQUIRED_PROTOCOL_VERSION)
222
222
  end
223
223
  end
224
224
 
@@ -266,7 +266,13 @@ module ActionMCP
266
266
  end
267
267
 
268
268
  # Convert to hash for rendering
269
- payload = result.message_json
269
+ payload = if result.respond_to?(:to_h)
270
+ result.to_h
271
+ elsif result.respond_to?(:to_json)
272
+ JSON.parse(result.to_json)
273
+ else
274
+ result
275
+ end
270
276
 
271
277
  # Determine response format
272
278
  server_preference = ActionMCP.configuration.post_response_preference
@@ -171,7 +171,11 @@ module ActionMCP
171
171
  )
172
172
 
173
173
  # Maintain cache limit by removing oldest events if needed
174
- sse_events.order(event_id: :asc).limit(sse_events.count - max_events).destroy_all if sse_events.count > max_events
174
+ count = sse_events.count
175
+ excess = count - max_events
176
+ if excess.positive?
177
+ sse_events.order(event_id: :asc).limit(excess).delete_all
178
+ end
175
179
 
176
180
  event
177
181
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "transport"
4
+
3
5
  module ActionMCP
4
6
  module Client
5
7
  # Base client class containing common MCP functionality
@@ -12,25 +14,20 @@ module ActionMCP
12
14
  include Roots
13
15
  include Logging
14
16
 
15
- attr_reader :logger, :type,
17
+ attr_reader :logger, :transport,
16
18
  :connection_error, :server,
17
19
  :server_capabilities, :session,
18
20
  :catalog, :blueprint,
19
21
  :prompt_book, :toolbox
20
22
 
21
- delegate :initialized?, to: :session
23
+ delegate :connected?, :ready?, to: :transport
22
24
 
23
- def initialize(logger: ActionMCP.logger)
25
+ def initialize(transport:, logger: ActionMCP.logger, **options)
24
26
  @logger = logger
25
- @connected = false
26
- @session = Session.from_client.new(
27
- protocol_version: PROTOCOL_VERSION,
28
- client_info: client_info,
29
- client_capabilities: client_capabilities
30
- )
27
+ @transport = transport
28
+ @session = nil # Session will be created/loaded based on server response
29
+ @session_id = options[:session_id] # Optional session ID for resumption
31
30
  @server_capabilities = nil
32
- @message_callback = nil
33
- @error_callback = nil
34
31
  @connection_error = nil
35
32
  @initialized = false
36
33
 
@@ -42,148 +39,157 @@ module ActionMCP
42
39
  @prompt_book = PromptBook.new([], self)
43
40
  # Tool objects
44
41
  @toolbox = Toolbox.new([], self)
45
- end
46
42
 
47
- def connected?
48
- @connected
43
+ setup_transport_callbacks
49
44
  end
50
45
 
51
- # Connect to the MCP server, if something went wrong at initialization
46
+ # Connect to the MCP server
52
47
  def connect
53
- return true if @connected
48
+ return true if connected?
54
49
 
55
50
  begin
56
- log_debug("Connecting to MCP server...")
51
+ log_debug("Connecting to MCP server via #{transport.class.name}...")
57
52
  @connection_error = nil
58
53
 
59
- # Start transport with proper error handling
60
- success = start_transport
61
-
54
+ success = @transport.connect
62
55
  unless success
63
- log_error("Failed to establish connection to MCP server")
56
+ log_error("Failed to establish transport connection")
64
57
  return false
65
58
  end
66
59
 
67
- @connected = true
68
60
  log_debug("Connected to MCP server")
69
-
70
- # Create handler only if it doesn't exist yet
71
- @json_rpc_handler ||= JsonRpcHandler.new(session, self)
72
-
73
- # Clear any existing message callback and set a new one
74
- @message_callback = lambda do |response|
75
- @json_rpc_handler.call(response)
76
- end
77
-
78
61
  true
79
62
  rescue StandardError => e
80
63
  @connection_error = e.message
81
64
  log_error("Failed to connect to MCP server: #{e.message}")
82
- @error_callback&.call(e)
83
65
  false
84
66
  end
85
67
  end
86
68
 
87
69
  # Disconnect from the MCP server
88
70
  def disconnect
89
- return true unless @connected
71
+ return true unless connected?
90
72
 
91
73
  begin
92
- stop_transport
93
- @connected = false
74
+ @transport.disconnect
94
75
  log_debug("Disconnected from MCP server")
95
76
  true
96
77
  rescue StandardError => e
97
78
  log_error("Error disconnecting from MCP server: #{e.message}")
98
- @error_callback&.call(e)
99
79
  false
100
80
  end
101
81
  end
102
82
 
103
- # Set a callback for incoming messages
104
- def on_message(&block)
105
- @message_callback = block
106
- end
107
-
108
- # Set a callback for errors
109
- def on_error(&block)
110
- @error_callback = block
111
- end
112
-
113
83
  # Send a request to the MCP server
114
84
  def write_message(payload)
115
- unless @connected
116
- log_error("Cannot send request - not connected")
85
+ unless ready?
86
+ log_error("Cannot send request - transport not ready")
117
87
  return false
118
88
  end
119
89
 
120
90
  begin
121
- session.write(payload)
91
+ # Only write to session if it exists (after initialization)
92
+ session.write(payload) if session
122
93
  data = payload.to_json unless payload.is_a?(String)
123
- send_message(data)
94
+ @transport.send_message(data)
124
95
  true
125
96
  rescue StandardError => e
126
97
  log_error("Failed to send request: #{e.message}")
127
- @error_callback&.call(e)
128
98
  false
129
99
  end
130
100
  end
131
101
 
132
- # Methods to be implemented by subclasses
133
- def start_transport
134
- raise NotImplementedError, "#{self.class} must implement #start_transport"
135
- end
136
-
137
- def stop_transport
138
- raise NotImplementedError, "#{self.class} must implement #stop_transport"
139
- end
140
-
141
- def send_message(json)
142
- raise NotImplementedError, "#{self.class} must implement #send_message"
143
- end
144
-
145
- def ready?
146
- raise NotImplementedError, "#{self.class} must implement #ready?"
147
- end
148
-
149
102
  def server=(server)
150
103
  @server = if server.is_a?(Client::Server)
151
104
  server
152
105
  else
153
106
  Client::Server.new(server)
154
107
  end
155
- session.server_capabilities = server.capabilities
156
- session.server_info = server.server_info
157
- session.save
108
+
109
+ # Only update session if it exists
110
+ if @session
111
+ @session.server_capabilities = server.capabilities
112
+ @session.server_info = server.server_info
113
+ @session.save
114
+ end
115
+ end
116
+
117
+ def initialized?
118
+ @initialized && @session&.initialized?
158
119
  end
159
120
 
160
121
  def inspect
161
- "#<#{self.class.name} server: #{server}, client_name: #{client_info[:name]}, client_version: #{client_info[:version]}, capabilities: #{client_capabilities} , connected: #{connected?}, initialized: #{initialized?}, session: #{session.id}>"
122
+ session_info = @session ? "session: #{@session.id}" : "session: none"
123
+ "#<#{self.class.name} transport: #{transport.class.name}, server: #{server}, client_name: #{client_info[:name]}, client_version: #{client_info[:version]}, capabilities: #{client_capabilities}, connected: #{connected?}, initialized: #{initialized?}, #{session_info}>"
162
124
  end
163
125
 
164
126
  protected
165
127
 
128
+ def setup_transport_callbacks
129
+ # Create JSON-RPC handler
130
+ @json_rpc_handler = JsonRpcHandler.new(session, self)
131
+
132
+ # Set up transport callbacks
133
+ @transport.on_message do |message|
134
+ handle_raw_message(message)
135
+ end
136
+
137
+ @transport.on_error do |error|
138
+ handle_transport_error(error)
139
+ end
140
+
141
+ @transport.on_connect do
142
+ handle_transport_connect
143
+ end
144
+
145
+ @transport.on_disconnect do
146
+ handle_transport_disconnect
147
+ end
148
+ end
149
+
166
150
  def handle_raw_message(raw)
167
- @message_callback&.call(raw)
151
+ @json_rpc_handler.call(raw)
168
152
  rescue MultiJson::ParseError => e
169
153
  log_error("JSON parse error: #{e} (raw: #{raw})")
170
- @error_callback&.call(e)
171
154
  rescue StandardError => e
172
155
  log_error("Error handling message: #{e} (raw: #{raw})")
173
- @error_callback&.call(e)
156
+ end
157
+
158
+ def handle_transport_error(error)
159
+ @connection_error = error.message
160
+ log_error("Transport error: #{error.message}")
161
+ end
162
+
163
+ def handle_transport_connect
164
+ log_debug("Transport connected")
165
+ # Send initial capabilities after connection
166
+ send_initial_capabilities
167
+ end
168
+
169
+ def handle_transport_disconnect
170
+ log_debug("Transport disconnected")
174
171
  end
175
172
 
176
173
  def send_initial_capabilities
177
174
  log_debug("Sending client capabilities")
178
- # We have contact! Let's send our CV to the recruiter.
179
- # We persist the session object to the database
180
- session.save
175
+
176
+ # If we have a session_id, we're trying to resume
177
+ if @session_id
178
+ log_debug("Attempting to resume session: #{@session_id}")
179
+ end
180
+
181
181
  params = {
182
- protocolVersion: session.protocol_version,
183
- capabilities: session.client_capabilities,
184
- clientInfo: session.client_info
182
+ protocolVersion: PROTOCOL_VERSION,
183
+ capabilities: client_capabilities,
184
+ clientInfo: client_info
185
185
  }
186
- send_jsonrpc_request("initialize", params: params, id: session.id)
186
+
187
+ # Include session_id if we're trying to resume
188
+ params[:sessionId] = @session_id if @session_id
189
+
190
+ # Use a unique request ID (not session ID since we don't have one yet)
191
+ request_id = SecureRandom.uuid
192
+ send_jsonrpc_request("initialize", params: params, id: request_id)
187
193
  end
188
194
 
189
195
  def client_capabilities
@@ -70,13 +70,16 @@ module ActionMCP
70
70
  end
71
71
 
72
72
  def process_response(id, result)
73
- if transport.id == id
74
- ## This initializes the transport
75
- client.server = Client::Server.new(result)
73
+ # Check if this is a response to an initialize request
74
+ # We need to check the actual request method, not just compare IDs
75
+ request = client.session ? transport.messages.requests.find_by(jsonrpc_id: id) : nil
76
+
77
+ # If no session yet, this might be the initialize response
78
+ if !client.session && result["serverInfo"]
79
+ handle_initialize_response(id, result)
76
80
  return send_initialized_notification
77
81
  end
78
82
 
79
- request = transport.messages.requests.find_by(jsonrpc_id: id)
80
83
  return unless request
81
84
 
82
85
  # Mark the request as acknowledged
@@ -113,8 +116,42 @@ module ActionMCP
113
116
  puts "\e[31mUnknown error: #{id} #{error}\e[0m"
114
117
  end
115
118
 
119
+ def handle_initialize_response(request_id, result)
120
+ # Session ID comes from HTTP headers, not the response body
121
+ # The transport should have already extracted it
122
+ session_id = transport.instance_variable_get(:@session_id)
123
+
124
+ if session_id.nil?
125
+ client.log_error("No session ID received from server")
126
+ return
127
+ end
128
+
129
+ # Check if we're resuming an existing session
130
+ if client.instance_variable_get(:@session_id) && session_id == client.instance_variable_get(:@session_id)
131
+ # We're resuming an existing session
132
+ client.instance_variable_set(:@session, ActionMCP::Session.find(session_id))
133
+ client.log_info("Resumed existing session: #{session_id}")
134
+ else
135
+ # Create a new session with the server-provided ID
136
+ client.instance_variable_set(:@session, ActionMCP::Session.from_client.new(
137
+ id: session_id,
138
+ protocol_version: result["protocolVersion"] || PROTOCOL_VERSION,
139
+ client_info: client.client_info,
140
+ client_capabilities: client.client_capabilities,
141
+ server_info: result["serverInfo"],
142
+ server_capabilities: result["capabilities"]
143
+ ))
144
+ client.session.save
145
+ client.log_info("Created new session: #{session_id}")
146
+ end
147
+
148
+ # Set the server info
149
+ client.server = Client::Server.new(result)
150
+ client.instance_variable_set(:@initialized, true)
151
+ end
152
+
116
153
  def send_initialized_notification
117
- transport.initialize!
154
+ transport.initialize! if transport.respond_to?(:initialize!)
118
155
  client.send_jsonrpc_notification("notifications/initialized")
119
156
  end
120
157
  end