actionmcp 0.51.0 → 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 (33) 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/lib/action_mcp/client/session_store.rb +117 -26
  5. data/lib/action_mcp/configuration.rb +16 -1
  6. data/lib/action_mcp/current.rb +19 -0
  7. data/lib/action_mcp/current_helpers.rb +19 -0
  8. data/lib/action_mcp/gateway.rb +85 -0
  9. data/lib/action_mcp/json_rpc_handler_base.rb +6 -1
  10. data/lib/action_mcp/jwt_decoder.rb +26 -0
  11. data/lib/action_mcp/prompt.rb +1 -0
  12. data/lib/action_mcp/resource_template.rb +1 -0
  13. data/lib/action_mcp/server/base_messaging.rb +14 -0
  14. data/lib/action_mcp/server/error_aware.rb +8 -1
  15. data/lib/action_mcp/server/handlers/tool_handler.rb +2 -1
  16. data/lib/action_mcp/server/json_rpc_handler.rb +12 -4
  17. data/lib/action_mcp/server/messaging.rb +12 -1
  18. data/lib/action_mcp/server/registry_management.rb +0 -1
  19. data/lib/action_mcp/server/response_collector.rb +40 -0
  20. data/lib/action_mcp/server/session_store.rb +762 -0
  21. data/lib/action_mcp/server/tools.rb +14 -3
  22. data/lib/action_mcp/server/transport_handler.rb +9 -5
  23. data/lib/action_mcp/server.rb +7 -0
  24. data/lib/action_mcp/tagged_stream_logging.rb +0 -4
  25. data/lib/action_mcp/test_helper/progress_notification_assertions.rb +105 -0
  26. data/lib/action_mcp/test_helper/session_store_assertions.rb +130 -0
  27. data/lib/action_mcp/test_helper.rb +4 -0
  28. data/lib/action_mcp/tool.rb +1 -0
  29. data/lib/action_mcp/version.rb +1 -1
  30. data/lib/action_mcp.rb +0 -1
  31. data/lib/generators/action_mcp/install/install_generator.rb +4 -0
  32. data/lib/generators/action_mcp/install/templates/application_gateway.rb +40 -0
  33. metadata +25 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 88b1f1dba04f05f96a9cf1c4ab928412400cb0a796d6635277924e57aa91a3c6
4
- data.tar.gz: 163a43c7d4e3e247ba6f1084250e9452e9f36ee0e2ba2ee4a1d6fe67ca95f147
3
+ metadata.gz: 123d51b56f85e45cb622885fb6bfcf6caf6d310292302a6ad4a9886e84c36af2
4
+ data.tar.gz: 15eb5bd01e1985a4eac9c6d83b463d0f939c02ccd330f72366fa0d08603c6f2b
5
5
  SHA512:
6
- metadata.gz: fae52dbb988f73cdcc4fa4678b12d352cdb151f1111c753a1210c37169b0b2d04dc53dbeafc092cd9dea6e6e5e2397672a7a1252982425f726f546427c52c327
7
- data.tar.gz: 613a305bd349a425d6f7169566b160eeda391870a20b8f997d01b587e558ef50ca005947a5482d24db5c4846a53e136f00301ad9f648f0b0c5d92c7a58b998f6
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
@@ -31,41 +31,41 @@ module ActionMCP
31
31
 
32
32
  session_data.merge!(attributes)
33
33
  save_session(session_id, session_data)
34
- session_data
34
+ # Return the reloaded session to get the actual saved values
35
+ load_session(session_id)
35
36
  end
36
37
  end
37
38
 
38
- # In-memory session store for development/testing
39
- class MemorySessionStore
39
+ # Volatile session store for development (data lost on restart)
40
+ class VolatileSessionStore
40
41
  include SessionStore
41
42
 
42
43
  def initialize
43
- @sessions = {}
44
- @mutex = Mutex.new
44
+ @sessions = Concurrent::Hash.new
45
45
  end
46
46
 
47
47
  def load_session(session_id)
48
- @mutex.synchronize { @sessions[session_id] }
48
+ @sessions[session_id]
49
49
  end
50
50
 
51
51
  def save_session(session_id, session_data)
52
- @mutex.synchronize { @sessions[session_id] = session_data.dup }
52
+ @sessions[session_id] = session_data.dup
53
53
  end
54
54
 
55
55
  def delete_session(session_id)
56
- @mutex.synchronize { @sessions.delete(session_id) }
56
+ @sessions.delete(session_id)
57
57
  end
58
58
 
59
59
  def session_exists?(session_id)
60
- @mutex.synchronize { @sessions.key?(session_id) }
60
+ @sessions.key?(session_id)
61
61
  end
62
62
 
63
63
  def clear_all
64
- @mutex.synchronize { @sessions.clear }
64
+ @sessions.clear
65
65
  end
66
66
 
67
67
  def session_count
68
- @mutex.synchronize { @sessions.size }
68
+ @sessions.size
69
69
  end
70
70
  end
71
71
 
@@ -84,8 +84,6 @@ module ActionMCP
84
84
  client_capabilities: session.client_capabilities,
85
85
  server_info: session.server_info,
86
86
  server_capabilities: session.server_capabilities,
87
- last_event_id: session.last_event_id,
88
- session_data: session.session_data || {},
89
87
  created_at: session.created_at,
90
88
  updated_at: session.updated_at
91
89
  }
@@ -94,16 +92,18 @@ module ActionMCP
94
92
  def save_session(session_id, session_data)
95
93
  session = ActionMCP::Session.find_or_initialize_by(id: session_id)
96
94
 
97
- session.assign_attributes(
98
- protocol_version: session_data[:protocol_version],
99
- client_info: session_data[:client_info],
100
- client_capabilities: session_data[:client_capabilities],
101
- server_info: session_data[:server_info],
102
- server_capabilities: session_data[:server_capabilities],
103
- last_event_id: session_data[:last_event_id],
104
- session_data: session_data[:session_data] || {}
105
- )
95
+ # Only assign attributes that exist in the database
96
+ attributes = {}
97
+ attributes[:protocol_version] = session_data[:protocol_version] if session_data.key?(:protocol_version)
98
+ attributes[:client_info] = session_data[:client_info] if session_data.key?(:client_info)
99
+ attributes[:client_capabilities] = session_data[:client_capabilities] if session_data.key?(:client_capabilities)
100
+ attributes[:server_info] = session_data[:server_info] if session_data.key?(:server_info)
101
+ attributes[:server_capabilities] = session_data[:server_capabilities] if session_data.key?(:server_capabilities)
102
+
103
+ # Store any extra data in a jsonb column if available
104
+ # For now, we'll skip last_event_id and session_data as they don't exist in the DB
106
105
 
106
+ session.assign_attributes(attributes)
107
107
  session.save!
108
108
  session_data
109
109
  end
@@ -121,20 +121,111 @@ module ActionMCP
121
121
  end
122
122
  end
123
123
 
124
+ # Test session store that tracks all operations for assertions
125
+ class TestSessionStore < VolatileSessionStore
126
+ attr_reader :operations, :saved_sessions, :loaded_sessions,
127
+ :deleted_sessions, :updated_sessions
128
+
129
+ def initialize
130
+ super
131
+ @operations = Concurrent::Array.new
132
+ @saved_sessions = Concurrent::Array.new
133
+ @loaded_sessions = Concurrent::Array.new
134
+ @deleted_sessions = Concurrent::Array.new
135
+ @updated_sessions = Concurrent::Array.new
136
+ end
137
+
138
+ def load_session(session_id)
139
+ session = super
140
+ @operations << { type: :load, session_id: session_id, found: !session.nil? }
141
+ @loaded_sessions << session_id if session
142
+ session
143
+ end
144
+
145
+ def save_session(session_id, session_data)
146
+ super
147
+ @operations << { type: :save, session_id: session_id, data: session_data }
148
+ @saved_sessions << session_id
149
+ end
150
+
151
+ def delete_session(session_id)
152
+ result = super
153
+ @operations << { type: :delete, session_id: session_id }
154
+ @deleted_sessions << session_id
155
+ result
156
+ end
157
+
158
+ def update_session(session_id, attributes)
159
+ result = super
160
+ @operations << { type: :update, session_id: session_id, attributes: attributes }
161
+ @updated_sessions << session_id if result
162
+ result
163
+ end
164
+
165
+ # Test helper methods
166
+ def session_saved?(session_id)
167
+ @saved_sessions.include?(session_id)
168
+ end
169
+
170
+ def session_loaded?(session_id)
171
+ @loaded_sessions.include?(session_id)
172
+ end
173
+
174
+ def session_deleted?(session_id)
175
+ @deleted_sessions.include?(session_id)
176
+ end
177
+
178
+ def session_updated?(session_id)
179
+ @updated_sessions.include?(session_id)
180
+ end
181
+
182
+ def operation_count(type = nil)
183
+ if type
184
+ @operations.count { |op| op[:type] == type }
185
+ else
186
+ @operations.size
187
+ end
188
+ end
189
+
190
+ def last_saved_data(session_id)
191
+ @operations.reverse.find { |op| op[:type] == :save && op[:session_id] == session_id }&.dig(:data)
192
+ end
193
+
194
+ def reset_tracking!
195
+ @operations.clear
196
+ @saved_sessions.clear
197
+ @loaded_sessions.clear
198
+ @deleted_sessions.clear
199
+ @updated_sessions.clear
200
+ end
201
+ end
202
+
124
203
  # Factory for creating session stores
125
204
  class SessionStoreFactory
126
205
  def self.create(type = nil, **options)
127
- type ||= Rails.env.production? ? :active_record : :memory
206
+ type ||= default_type
128
207
 
129
208
  case type.to_sym
130
- when :memory
131
- MemorySessionStore.new
132
- when :active_record
209
+ when :volatile, :memory
210
+ VolatileSessionStore.new
211
+ when :active_record, :persistent
133
212
  ActiveRecordSessionStore.new
213
+ when :test
214
+ TestSessionStore.new
134
215
  else
135
216
  raise ArgumentError, "Unknown session store type: #{type}"
136
217
  end
137
218
  end
219
+
220
+ def self.default_type
221
+ if Rails.env.test?
222
+ :volatile # Use volatile for tests unless explicitly using :test
223
+ elsif Rails.env.production?
224
+ :active_record
225
+ else
226
+ :volatile
227
+ end
228
+ end
138
229
  end
139
230
  end
140
231
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "gateway"
4
+ require "active_support/core_ext/integer/time"
5
+
3
6
  module ActionMCP
4
7
  # Configuration class to hold settings for the ActionMCP server.
5
8
  class Configuration
@@ -30,7 +33,12 @@ module ActionMCP
30
33
  :vibed_ignore_version,
31
34
  # --- SSE Resumability Options ---
32
35
  :sse_event_retention_period,
33
- :max_stored_sse_events
36
+ :max_stored_sse_events,
37
+ # --- Gateway Options ---
38
+ :gateway_class,
39
+ :current_class,
40
+ # --- Session Store Options ---
41
+ :session_store_type
34
42
 
35
43
  def initialize
36
44
  @logging_enabled = true
@@ -47,6 +55,13 @@ module ActionMCP
47
55
  # Resumability defaults
48
56
  @sse_event_retention_period = 15.minutes
49
57
  @max_stored_sse_events = 100
58
+
59
+ # Gateway - default to ApplicationGateway if it exists, otherwise ActionMCP::Gateway
60
+ @gateway_class = defined?(::ApplicationGateway) ? ::ApplicationGateway : ActionMCP::Gateway
61
+ @current_class = nil
62
+
63
+ # Session Store
64
+ @session_store_type = Rails.env.production? ? :active_record : :volatile
50
65
  end
51
66
 
52
67
  def name
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ class Current < ActiveSupport::CurrentAttributes
5
+ attribute :user
6
+ attribute :gateway
7
+
8
+ def user=(user)
9
+ super
10
+ set_user_time_zone if user.respond_to?(:time_zone)
11
+ end
12
+
13
+ private
14
+
15
+ def set_user_time_zone
16
+ Time.zone = user.time_zone
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module CurrentHelpers
5
+ extend ActiveSupport::Concern
6
+
7
+ protected
8
+
9
+ # Access the current user from ActionMCP::Current
10
+ def current_user
11
+ ActionMCP::Current.user
12
+ end
13
+
14
+ # Access the current gateway from ActionMCP::Current
15
+ def current_gateway
16
+ ActionMCP::Current.gateway
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ class UnauthorizedError < StandardError; end
5
+
6
+ class Gateway
7
+ class << self
8
+ def identified_by(*attrs)
9
+ @identifiers ||= []
10
+ @identifiers.concat(attrs.map(&:to_sym)).uniq!
11
+ attr_accessor(*attrs)
12
+ end
13
+
14
+ def identifiers
15
+ @identifiers ||= []
16
+ end
17
+ end
18
+
19
+ identified_by :user
20
+
21
+ attr_reader :request
22
+
23
+ def call(request)
24
+ @request = request
25
+ connect
26
+ self
27
+ end
28
+
29
+ def connect
30
+ identities = authenticate!
31
+ reject_unauthorized_connection unless identities.is_a?(Hash)
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
46
+ end
47
+
48
+
49
+ protected
50
+
51
+ def authenticate!
52
+ token = extract_bearer_token
53
+ raise UnauthorizedError, "Missing token" unless token
54
+
55
+ payload = ActionMCP::JwtDecoder.decode(token)
56
+ resolve_user(payload)
57
+ rescue ActionMCP::JwtDecoder::DecodeError => e
58
+ raise UnauthorizedError, e.message
59
+ end
60
+
61
+ def extract_bearer_token
62
+ header = request.headers["Authorization"] || request.headers["authorization"]
63
+ return nil unless header&.start_with?("Bearer ")
64
+ header.split(" ", 2).last
65
+ end
66
+
67
+ def resolve_user(payload)
68
+ return nil unless payload.is_a?(Hash)
69
+ user_id = payload["user_id"] || payload["sub"]
70
+ return nil unless user_id
71
+ user = User.find_by(id: user_id)
72
+ return nil unless user
73
+
74
+ # Return a hash with all identified_by attributes
75
+ self.class.identifiers.each_with_object({}) do |identifier, hash|
76
+ hash[identifier] = user if identifier == :user
77
+ # Add support for other identifiers as needed
78
+ end
79
+ end
80
+
81
+ def reject_unauthorized_connection
82
+ raise UnauthorizedError, "Unauthorized"
83
+ end
84
+ end
85
+ end
@@ -51,13 +51,18 @@ module ActionMCP
51
51
  # @param rpc_method [String]
52
52
  # @param id [String, Integer]
53
53
  # @param params [Hash]
54
- # @return [Boolean] true if handled, false otherwise
54
+ # @return [JSON_RPC::Response, nil] Response if handled, nil otherwise
55
55
  def handle_common_methods(rpc_method, id, params)
56
56
  case rpc_method
57
57
  when Methods::PING
58
58
  transport.send_pong(id)
59
+ # In return mode, get the response that was just created
60
+ transport.messaging_mode == :return ? transport.get_last_response : true
59
61
  when %r{^notifications/}
60
62
  process_notifications(rpc_method, params)
63
+ true
64
+ else
65
+ nil
61
66
  end
62
67
  end
63
68
 
@@ -0,0 +1,26 @@
1
+ require "jwt"
2
+
3
+ module ActionMCP
4
+ class JwtDecoder
5
+ class DecodeError < StandardError; end
6
+
7
+ # Configurable defaults
8
+ class << self
9
+ attr_accessor :secret, :algorithm
10
+
11
+ def decode(token)
12
+ payload, _header = JWT.decode(token, secret, true, { algorithm: algorithm })
13
+ payload
14
+ rescue JWT::ExpiredSignature
15
+ raise DecodeError, "Token has expired"
16
+ rescue JWT::DecodeError => e
17
+ # Simplify the error message for invalid tokens
18
+ raise DecodeError, "Invalid token"
19
+ end
20
+ end
21
+
22
+ # Defaults (can be overridden in an initializer)
23
+ self.secret = ENV.fetch("ACTION_MCP_JWT_SECRET") { "change-me" }
24
+ self.algorithm = "HS256"
25
+ end
26
+ end
@@ -4,6 +4,7 @@ module ActionMCP
4
4
  # Abstract base class for Prompts
5
5
  class Prompt < Capability
6
6
  include ActionMCP::Callbacks
7
+ include ActionMCP::CurrentHelpers
7
8
  class_attribute :_argument_definitions, instance_accessor: false, default: []
8
9
 
9
10
  # ---------------------------------------------------
@@ -10,6 +10,7 @@ module ActionMCP
10
10
  include ResourceCallbacks
11
11
  include Logging
12
12
  include UriAmbiguityChecker
13
+ include CurrentHelpers
13
14
 
14
15
  # Track all registered templates
15
16
  @registered_templates = []
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Server
5
+ # Base messaging functionality
6
+ module BaseMessaging
7
+ private
8
+
9
+ def write_message(data)
10
+ session.write(data)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -24,7 +24,14 @@ module ActionMCP
24
24
  def with_error_handling(request_id)
25
25
  yield
26
26
  rescue JSON_RPC::JsonRpcError => e
27
- error_response(request_id, e)
27
+ if transport.messaging_mode == :return
28
+ response = error_response(request_id, e)
29
+ transport.write_message(response)
30
+ response
31
+ else
32
+ transport.send_jsonrpc_response(request_id, error: e)
33
+ nil
34
+ end
28
35
  end
29
36
  end
30
37
  end
@@ -36,7 +36,8 @@ module ActionMCP
36
36
  def handle_tools_call(id, params)
37
37
  name = validate_required_param(params, "name", "Tool name is required")
38
38
  arguments = extract_arguments(params)
39
- transport.send_tools_call(id, name, arguments)
39
+ _meta = params["_meta"] || params[:_meta] || {}
40
+ transport.send_tools_call(id, name, arguments, _meta)
40
41
  end
41
42
 
42
43
  def extract_arguments(params)
@@ -31,11 +31,18 @@ module ActionMCP
31
31
  rpc_method = request.method
32
32
  params = request.params
33
33
 
34
- with_error_handling(id) do
35
- common_method = handle_common_methods(rpc_method, id, params)
36
- return common_method if common_method
37
- route_to_handler(rpc_method, id, params)
34
+ result = with_error_handling(id) do
35
+ common_result = handle_common_methods(rpc_method, id, params)
36
+ if common_result
37
+ common_result
38
+ else
39
+ route_to_handler(rpc_method, id, params)
40
+ # In return mode, get the last response that was collected
41
+ transport.messaging_mode == :return ? transport.get_last_response : nil
42
+ end
38
43
  end
44
+
45
+ result
39
46
  end
40
47
 
41
48
  def route_to_handler(rpc_method, id, params)
@@ -64,6 +71,7 @@ module ActionMCP
64
71
  params = notification.params || {}
65
72
 
66
73
  process_notifications(method_name, params)
74
+ # Notifications don't expect a response
67
75
  nil
68
76
  end
69
77