actionmcp 0.31.1 → 0.32.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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -5
  3. data/app/controllers/action_mcp/mcp_controller.rb +13 -17
  4. data/app/controllers/action_mcp/messages_controller.rb +3 -1
  5. data/app/controllers/action_mcp/sse_controller.rb +22 -4
  6. data/app/controllers/action_mcp/unified_controller.rb +147 -52
  7. data/app/models/action_mcp/session/message.rb +1 -0
  8. data/app/models/action_mcp/session/sse_event.rb +55 -0
  9. data/app/models/action_mcp/session.rb +235 -12
  10. data/app/models/concerns/mcp_console_helpers.rb +68 -0
  11. data/app/models/concerns/mcp_message_inspect.rb +73 -0
  12. data/config/routes.rb +4 -2
  13. data/db/migrate/20250329120300_add_registries_to_sessions.rb +9 -0
  14. data/db/migrate/20250329150312_create_action_mcp_sse_events.rb +16 -0
  15. data/lib/action_mcp/capability.rb +16 -0
  16. data/lib/action_mcp/configuration.rb +16 -4
  17. data/lib/action_mcp/console_detector.rb +12 -0
  18. data/lib/action_mcp/engine.rb +3 -0
  19. data/lib/action_mcp/json_rpc_handler_base.rb +1 -1
  20. data/lib/action_mcp/resource_template.rb +11 -0
  21. data/lib/action_mcp/server/capabilities.rb +28 -22
  22. data/lib/action_mcp/server/json_rpc_handler.rb +35 -9
  23. data/lib/action_mcp/server/notifications.rb +14 -5
  24. data/lib/action_mcp/server/prompts.rb +18 -5
  25. data/lib/action_mcp/server/registry_management.rb +32 -0
  26. data/lib/action_mcp/server/resources.rb +3 -2
  27. data/lib/action_mcp/server/tools.rb +50 -6
  28. data/lib/action_mcp/sse_listener.rb +3 -2
  29. data/lib/action_mcp/tagged_stream_logging.rb +47 -0
  30. data/lib/action_mcp/test_helper.rb +57 -34
  31. data/lib/action_mcp/tool.rb +45 -9
  32. data/lib/action_mcp/version.rb +1 -1
  33. data/lib/action_mcp.rb +4 -4
  34. metadata +25 -20
@@ -10,12 +10,15 @@
10
10
  # ended_at(The time the session ended) :datetime
11
11
  # initialized :boolean default(FALSE), not null
12
12
  # messages_count :integer default(0), not null
13
+ # prompt_registry :jsonb
13
14
  # protocol_version :string
15
+ # resource_registry :jsonb
14
16
  # role(The role of the session) :string default("server"), not null
15
17
  # server_capabilities(The capabilities of the server) :jsonb
16
18
  # server_info(The information about the server) :jsonb
17
19
  # sse_event_counter :integer default(0), not null
18
20
  # status :string default("pre_initialize"), not null
21
+ # tool_registry :jsonb
19
22
  # created_at :datetime not null
20
23
  # updated_at :datetime not null
21
24
  #
@@ -26,6 +29,7 @@ module ActionMCP
26
29
  # such as client and server capabilities, protocol version, and session status.
27
30
  # It also manages the association with messages and subscriptions related to the session.
28
31
  class Session < ApplicationRecord
32
+ include MCPConsoleHelpers
29
33
  attribute :id, :string, default: -> { SecureRandom.hex(6) }
30
34
  has_many :messages,
31
35
  class_name: "ActionMCP::Session::Message",
@@ -43,17 +47,24 @@ module ActionMCP
43
47
  dependent: :delete_all,
44
48
  inverse_of: :session
45
49
 
50
+ has_many :sse_events,
51
+ class_name: "ActionMCP::Session::SSEEvent",
52
+ foreign_key: "session_id",
53
+ dependent: :delete_all,
54
+ inverse_of: :session
55
+
46
56
  scope :pre_initialize, -> { where(status: "pre_initialize") }
47
57
  scope :closed, -> { where(status: "closed") }
48
58
  scope :without_messages, -> { includes(:messages).where(action_mcp_session_messages: { id: nil }) }
49
59
 
50
60
  scope :from_server, -> { where(role: "server") }
51
61
  scope :from_client, -> { where(role: "client") }
52
-
62
+ # Initialize with default registries
63
+ before_create :initialize_registries
53
64
  before_create :set_server_info, if: -> { role == "server" }
54
65
  before_create :set_server_capabilities, if: -> { role == "server" }
55
66
 
56
- validates :protocol_version, inclusion: { in: [ PROTOCOL_VERSION ] }, allow_nil: true
67
+ validates :protocol_version, inclusion: { in: SUPPORTED_VERSIONS }, allow_nil: true
57
68
 
58
69
  def close!
59
70
  dummy_callback = ->(*) { } # this callback seem broken
@@ -119,16 +130,6 @@ module ActionMCP
119
130
  save
120
131
  end
121
132
 
122
- def message_flow
123
- messages.without_pings.order(created_at: :asc).map do |message|
124
- {
125
- direction: message.direction,
126
- data: message.data,
127
- type: message.message_type
128
- }
129
- end
130
- end
131
-
132
133
  def send_ping!
133
134
  Session.logger.silence do
134
135
  write(JSON_RPC::Request.new(id: Time.now.to_i, method: "ping"))
@@ -153,6 +154,163 @@ module ActionMCP
153
154
  reload.sse_event_counter
154
155
  end
155
156
 
157
+ # Stores an SSE event for potential resumption
158
+ # @param event_id [Integer] The event ID
159
+ # @param data [Hash, String] The event data
160
+ # @param max_events [Integer] Maximum number of events to store (oldest events are removed when exceeded)
161
+ # @return [ActionMCP::Session::SSEEvent] The created event
162
+ def store_sse_event(event_id, data, max_events = 100)
163
+ # Create the SSE event record
164
+ event = sse_events.create!(
165
+ event_id: event_id,
166
+ data: data
167
+ )
168
+
169
+ # Maintain cache limit by removing oldest events if needed
170
+ if sse_events.count > max_events
171
+ sse_events.order(event_id: :asc).limit(sse_events.count - max_events).destroy_all
172
+ end
173
+
174
+ event
175
+ end
176
+
177
+ # Retrieves SSE events after a given ID
178
+ # @param last_event_id [Integer] The ID to retrieve events after
179
+ # @param limit [Integer] Maximum number of events to return
180
+ # @return [Array<ActionMCP::Session::SSEEvent>] The events
181
+ def get_sse_events_after(last_event_id, limit = 50)
182
+ sse_events.where("event_id > ?", last_event_id)
183
+ .order(event_id: :asc)
184
+ .limit(limit)
185
+ end
186
+
187
+ # Cleans up old SSE events
188
+ # @param max_age [ActiveSupport::Duration] Maximum age of events to keep
189
+ # @return [Integer] Number of events removed
190
+ def cleanup_old_sse_events(max_age = 15.minutes)
191
+ cutoff_time = Time.current - max_age
192
+ events_to_delete = sse_events.where("created_at < ?", cutoff_time)
193
+ count = events_to_delete.count
194
+ events_to_delete.destroy_all
195
+ count
196
+ end
197
+
198
+ def send_progress_notification(progressToken:, progress:, total: nil, message: nil)
199
+ # Create a transport handler to send the notification
200
+ handler = ActionMCP::Server::TransportHandler.new(self)
201
+ handler.send_progress_notification(
202
+ progressToken: progressToken,
203
+ progress: progress,
204
+ total: total,
205
+ message: message
206
+ )
207
+ end
208
+
209
+ # Calculates the retention period for SSE events based on configuration
210
+ # @return [ActiveSupport::Duration] The retention period
211
+ def sse_event_retention_period
212
+ ActionMCP.configuration.sse_event_retention_period || 15.minutes
213
+ end
214
+
215
+ # Calculates the maximum number of SSE events to store based on configuration
216
+ # @return [Integer] The maximum number of events
217
+ def max_stored_sse_events
218
+ ActionMCP.configuration.max_stored_sse_events || 100
219
+ end
220
+
221
+ def send_progress_notification_legacy(token:, value:, message: nil)
222
+ send_progress_notification(progressToken: token, progress: value, message: message)
223
+ end
224
+
225
+ # Registry management methods
226
+ def register_tool(tool_class_or_name)
227
+ tool_name = normalize_name(tool_class_or_name, :tool)
228
+ return false unless tool_exists?(tool_name)
229
+
230
+ self.tool_registry ||= []
231
+ unless self.tool_registry.include?(tool_name)
232
+ self.tool_registry << tool_name
233
+ save!
234
+ send_tools_list_changed_notification
235
+ end
236
+ true
237
+ end
238
+
239
+ def unregister_tool(tool_class_or_name)
240
+ tool_name = normalize_name(tool_class_or_name, :tool)
241
+ self.tool_registry ||= []
242
+
243
+ if self.tool_registry.delete(tool_name)
244
+ save!
245
+ send_tools_list_changed_notification
246
+ end
247
+ end
248
+
249
+ def register_prompt(prompt_class_or_name)
250
+ prompt_name = normalize_name(prompt_class_or_name, :prompt)
251
+ return false unless prompt_exists?(prompt_name)
252
+
253
+ self.prompt_registry ||= []
254
+ unless self.prompt_registry.include?(prompt_name)
255
+ self.prompt_registry << prompt_name
256
+ save!
257
+ send_prompts_list_changed_notification
258
+ end
259
+ true
260
+ end
261
+
262
+ def unregister_prompt(prompt_class_or_name)
263
+ prompt_name = normalize_name(prompt_class_or_name, :prompt)
264
+ self.prompt_registry ||= []
265
+
266
+ if self.prompt_registry.delete(prompt_name)
267
+ save!
268
+ send_prompts_list_changed_notification
269
+ end
270
+ end
271
+
272
+ def register_resource_template(template_class_or_name)
273
+ template_name = normalize_name(template_class_or_name, :resource_template)
274
+ return false unless resource_template_exists?(template_name)
275
+
276
+ self.resource_registry ||= []
277
+ unless self.resource_registry.include?(template_name)
278
+ self.resource_registry << template_name
279
+ save!
280
+ send_resources_list_changed_notification
281
+ end
282
+ true
283
+ end
284
+
285
+ def unregister_resource_template(template_class_or_name)
286
+ template_name = normalize_name(template_class_or_name, :resource_template)
287
+ self.resource_registry ||= []
288
+
289
+ if self.resource_registry.delete(template_name)
290
+ save!
291
+ send_resources_list_changed_notification
292
+ end
293
+ end
294
+
295
+ # Get registered items for this session
296
+ def registered_tools
297
+ (self.tool_registry || []).filter_map do |tool_name|
298
+ ActionMCP::ToolsRegistry.find(tool_name) rescue nil
299
+ end
300
+ end
301
+
302
+ def registered_prompts
303
+ (self.prompt_registry || []).filter_map do |prompt_name|
304
+ ActionMCP::PromptsRegistry.find(prompt_name) rescue nil
305
+ end
306
+ end
307
+
308
+ def registered_resource_templates
309
+ (self.resource_registry || []).filter_map do |template_name|
310
+ ActionMCP::ResourceTemplatesRegistry.find(template_name) rescue nil
311
+ end
312
+ end
313
+
156
314
  private
157
315
 
158
316
  # if this session is from a server, the writer is the client
@@ -172,5 +330,70 @@ module ActionMCP
172
330
  def set_server_capabilities
173
331
  self.server_capabilities ||= ActionMCP.configuration.capabilities
174
332
  end
333
+
334
+ def initialize_registries
335
+ # Start with default registries from configuration
336
+ self.tool_registry = ActionMCP.configuration.filtered_tools.map(&:name)
337
+ self.prompt_registry = ActionMCP.configuration.filtered_prompts.map(&:name)
338
+ self.resource_registry = ActionMCP.configuration.filtered_resources.map(&:name)
339
+ end
340
+
341
+ def normalize_name(class_or_name, type)
342
+ case class_or_name
343
+ when String
344
+ class_or_name
345
+ when Class
346
+ case type
347
+ when :tool
348
+ class_or_name.tool_name
349
+ when :prompt
350
+ class_or_name.prompt_name
351
+ when :resource_template
352
+ class_or_name.capability_name
353
+ end
354
+ else
355
+ raise ArgumentError, "Expected String or Class, got #{class_or_name.class}"
356
+ end
357
+ end
358
+
359
+ def tool_exists?(tool_name)
360
+ ActionMCP::ToolsRegistry.find(tool_name)
361
+ true
362
+ rescue ActionMCP::RegistryBase::NotFound
363
+ false
364
+ end
365
+
366
+ def prompt_exists?(prompt_name)
367
+ ActionMCP::PromptsRegistry.find(prompt_name)
368
+ true
369
+ rescue ActionMCP::RegistryBase::NotFound
370
+ false
371
+ end
372
+
373
+ def resource_template_exists?(template_name)
374
+ ActionMCP::ResourceTemplatesRegistry.find(template_name)
375
+ true
376
+ rescue ActionMCP::RegistryBase::NotFound
377
+ false
378
+ end
379
+
380
+ def send_tools_list_changed_notification
381
+ # Only send if server capabilities allow it
382
+ if server_capabilities.dig("tools", "listChanged")
383
+ write(JSON_RPC::Notification.new(method: "notifications/tools/list_changed"))
384
+ end
385
+ end
386
+
387
+ def send_prompts_list_changed_notification
388
+ if server_capabilities.dig("prompts", "listChanged")
389
+ write(JSON_RPC::Notification.new(method: "notifications/prompts/list_changed"))
390
+ end
391
+ end
392
+
393
+ def send_resources_list_changed_notification
394
+ if server_capabilities.dig("resources", "listChanged")
395
+ write(JSON_RPC::Notification.new(method: "notifications/resources/list_changed"))
396
+ end
397
+ end
175
398
  end
176
399
  end
@@ -0,0 +1,68 @@
1
+ # app/models/concerns/mcp_console_helpers.rb
2
+ module MCPConsoleHelpers
3
+ extend ActiveSupport::Concern
4
+
5
+ class_methods do
6
+ def pretty_messages(session_or_messages, limit: 10)
7
+ messages = if session_or_messages.respond_to?(:messages)
8
+ session_or_messages.messages.order(:created_at).last(limit)
9
+ else
10
+ session_or_messages.last(limit)
11
+ end
12
+
13
+ messages.each do |msg|
14
+ puts msg.inspect
15
+ if msg.data&.dig("method")
16
+ puts " └─ #{msg.data['method']}"
17
+ end
18
+ puts
19
+ end
20
+ end
21
+
22
+ def message_flow(session, limit: 50)
23
+ puts "\nMCP Message Flow:"
24
+ puts "Session ID: #{session.id}"
25
+ puts "Protocol: #{session.protocol_version}"
26
+ puts "─" * 70
27
+
28
+ session.messages.order(:created_at).last(limit).each do |msg|
29
+ time = msg.created_at.strftime("%H:%M:%S.%3N")
30
+ arrow = msg.direction == "client" ? "→" : "←"
31
+ direction_label = msg.direction == "client" ? "CLIENT" : "SERVER"
32
+
33
+ if ActionMCP::ConsoleDetector.in_console?
34
+ color_code = case msg.message_type
35
+ when "request" then "\e[34m"
36
+ when "response" then "\e[32m"
37
+ when "error" then "\e[31m"
38
+ when "notification" then "\e[33m"
39
+ else "\e[90m"
40
+ end
41
+ puts "#{time} #{color_code}#{direction_label}\e[0m #{arrow} #{msg.inspect}"
42
+ else
43
+ puts "#{time} #{direction_label} #{arrow} #{msg.inspect}"
44
+ end
45
+ end
46
+
47
+ puts "─" * 70
48
+ end
49
+
50
+ def message_stats(session)
51
+ stats = session.messages.group(:message_type, :direction).count
52
+
53
+ puts "\nMessage Statistics:"
54
+ puts "─" * 40
55
+
56
+ stats.each do |(type, direction), count|
57
+ puts "#{type.ljust(15)} #{direction.ljust(10)} #{count}"
58
+ end
59
+
60
+ puts "─" * 40
61
+ puts "Total: #{session.messages.count}"
62
+ end
63
+ end
64
+
65
+ def message_flow(limit: 50)
66
+ self.class.message_flow(self, limit: limit)
67
+ end
68
+ end
@@ -0,0 +1,73 @@
1
+ # app/models/concerns/mcp_message_inspect.rb
2
+ module MCPMessageInspect
3
+ extend ActiveSupport::Concern
4
+
5
+ def inspect(show_data: false)
6
+ if show_data
7
+ super() # Rails default inspect
8
+ else
9
+ build_summary_inspect
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def build_summary_inspect
16
+ case message_type
17
+ when "request"
18
+ format_request_summary
19
+ when "response", "error"
20
+ format_response_summary
21
+ when "notification"
22
+ format_notification_summary
23
+ else
24
+ format_default_summary
25
+ end
26
+ end
27
+
28
+ def format_request_summary
29
+ method = data&.dig("method")
30
+ formatted = "#<Message #{id}: REQUEST #{jsonrpc_id} -> #{method}>"
31
+ console? ? colorize(formatted, :blue) : formatted
32
+ end
33
+
34
+ def format_response_summary
35
+ formatted = "#<Message #{id}: #{message_type.upcase} #{jsonrpc_id}>"
36
+ if console?
37
+ color = message_type == "error" ? :red : :green
38
+ colorize(formatted, color)
39
+ else
40
+ formatted
41
+ end
42
+ end
43
+
44
+ def format_notification_summary
45
+ method = data&.dig("method")
46
+ formatted = "#<Message #{id}: NOTIFICATION -> #{method}>"
47
+ console? ? colorize(formatted, :yellow) : formatted
48
+ end
49
+
50
+ def format_default_summary
51
+ formatted = "#<Message #{id}: #{message_type.upcase}>"
52
+ console? ? colorize(formatted, :gray) : formatted
53
+ end
54
+
55
+ def console?
56
+ # Check if we're in a Rails console environment
57
+ defined?(Rails::Console) ||
58
+ defined?(::Rails.application) && Rails.application.console? ||
59
+ (defined?(IRB) && IRB.CurrentContext.kind_of?(IRB::ExtendCommandBundle))
60
+ end
61
+
62
+ def colorize(text, color)
63
+ colors = {
64
+ blue: "\e[34m",
65
+ green: "\e[32m",
66
+ red: "\e[31m",
67
+ yellow: "\e[33m",
68
+ gray: "\e[90m"
69
+ }
70
+
71
+ "#{colors[color]}#{text}\e[0m"
72
+ end
73
+ end
data/config/routes.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  ActionMCP::Engine.routes.draw do
4
+ get "/up", to: "/rails/health#show", as: :action_mcp_health_check
4
5
  # --- Routes for 2024-11-05 Spec (HTTP+SSE) ---
5
6
  # Kept for backward compatibility
6
7
  get "/", to: "sse#events", as: :sse_out
@@ -8,6 +9,7 @@ ActionMCP::Engine.routes.draw do
8
9
 
9
10
  # --- Routes for 2025-03-26 Spec (Streamable HTTP) ---
10
11
  mcp_endpoint = ActionMCP.configuration.mcp_endpoint_path
11
- get mcp_endpoint, to: "unified#handle_get", as: :mcp_get
12
- post mcp_endpoint, to: "unified#handle_post", as: :mcp_post
12
+ get mcp_endpoint, to: "unified#show", as: :mcp_get
13
+ post mcp_endpoint, to: "unified#create", as: :mcp_post
14
+ delete mcp_endpoint, to: "unified#destroy", as: :mcp_delete
13
15
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddRegistriesToSessions < ActiveRecord::Migration[8.0]
4
+ def change
5
+ add_column :action_mcp_sessions, :tool_registry, :jsonb, default: []
6
+ add_column :action_mcp_sessions, :prompt_registry, :jsonb, default: []
7
+ add_column :action_mcp_sessions, :resource_registry, :jsonb, default: []
8
+ end
9
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateActionMCPSSEEvents < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :action_mcp_sse_events do |t|
6
+ t.references :session, null: false, foreign_key: { to_table: :action_mcp_sessions }, index: true, type: :string
7
+ t.integer :event_id, null: false
8
+ t.text :data, null: false
9
+ t.timestamps
10
+
11
+ # Index for efficiently retrieving events after a given ID for a specific session
12
+ t.index [ :session_id, :event_id ], unique: true
13
+ t.index :created_at # For cleanup of old events
14
+ end
15
+ end
16
+ end
@@ -14,6 +14,22 @@ module ActionMCP
14
14
  class_attribute :_capability_name, instance_accessor: false
15
15
  class_attribute :_description, instance_accessor: false, default: ""
16
16
 
17
+ attr_reader :execution_context
18
+
19
+ def initialize(*)
20
+ super
21
+ @execution_context = {}
22
+ end
23
+
24
+ def with_context(context)
25
+ @execution_context = context
26
+ self
27
+ end
28
+
29
+ def session
30
+ execution_context[:session]
31
+ end
32
+
17
33
  # use _capability_name or default_capability_name
18
34
  def self.capability_name
19
35
  _capability_name || default_capability_name
@@ -23,10 +23,14 @@ module ActionMCP
23
23
  :active_profile,
24
24
  :profiles,
25
25
  # --- New Transport Options ---
26
- :allow_client_session_termination,
27
26
  :mcp_endpoint_path,
28
27
  :sse_heartbeat_interval,
29
- :post_response_preference # :json or :sse
28
+ :post_response_preference, # :json or :sse
29
+ :protocol_version,
30
+ # --- SSE Resumability Options ---
31
+ :enable_sse_resumability,
32
+ :sse_event_retention_period,
33
+ :max_stored_sse_events
30
34
 
31
35
  def initialize
32
36
  @logging_enabled = true
@@ -36,10 +40,15 @@ module ActionMCP
36
40
  @active_profile = :primary
37
41
  @profiles = default_profiles
38
42
 
39
- @allow_client_session_termination = true
40
43
  @mcp_endpoint_path = "/mcp"
41
44
  @sse_heartbeat_interval = 30
42
45
  @post_response_preference = :json
46
+ @protocol_version = "2024-11-05"
47
+
48
+ # Resumability defaults
49
+ @enable_sse_resumability = true
50
+ @sse_event_retention_period = 15.minutes
51
+ @max_stored_sse_events = 100
43
52
  end
44
53
 
45
54
  def name
@@ -132,6 +141,9 @@ module ActionMCP
132
141
 
133
142
  capabilities[:resources] = { subscribe: @resources_subscribe } if filtered_resources.any?
134
143
 
144
+ # Add resumability capability if enabled
145
+ capabilities[:resumability] = { enabled: @enable_sse_resumability } if @enable_sse_resumability
146
+
135
147
  capabilities
136
148
  end
137
149
 
@@ -193,7 +205,7 @@ module ActionMCP
193
205
  end
194
206
 
195
207
  class << self
196
- attr_accessor :server, :logger
208
+ attr_accessor :logger
197
209
 
198
210
  # Thread-local storage for active profiles
199
211
  def thread_profiles
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module ConsoleDetector
5
+ module_function
6
+
7
+ def in_console?
8
+ # Check for Rails console
9
+ defined?(Rails::Console)
10
+ end
11
+ end
12
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "rails"
4
4
  require "active_model/railtie"
5
+ require "jsonrpc-rails"
5
6
 
6
7
  module ActionMCP
7
8
  # Engine for integrating ActionMCP with Rails applications.
@@ -20,6 +21,8 @@ module ActionMCP
20
21
  ActionMCP::ResourceTemplate.registered_templates.clear
21
22
  end
22
23
 
24
+ config.middleware.use JSONRPC_Rails::Middleware::Validator, [ ActionMCP.configuration.mcp_endpoint_path ]
25
+
23
26
  # Load MCP profiles during initialization
24
27
  initializer "action_mcp.load_profiles" do
25
28
  ActionMCP.configuration.load_profiles
@@ -75,9 +75,9 @@ module ActionMCP
75
75
  # @param request [Hash]
76
76
  def process_request(request)
77
77
  return unless valid_request?(request)
78
+ request = request.with_indifferent_access
78
79
 
79
80
  read(request)
80
-
81
81
  id = request["id"]
82
82
 
83
83
  unless (rpc_method = request["method"])
@@ -13,6 +13,7 @@ module ActionMCP
13
13
 
14
14
  # Track all registered templates
15
15
  @registered_templates = []
16
+ attr_reader :execution_context
16
17
 
17
18
  class << self
18
19
  attr_reader :registered_templates, :description, :uri_template,
@@ -201,6 +202,7 @@ module ActionMCP
201
202
  # Initialize with attribute values
202
203
  def initialize(attributes = {})
203
204
  super(attributes)
205
+ @execution_context = {}
204
206
  validate!
205
207
  end
206
208
 
@@ -209,6 +211,15 @@ module ActionMCP
209
211
  valid?
210
212
  end
211
213
 
214
+ def with_context(context)
215
+ @execution_context = context
216
+ self
217
+ end
218
+
219
+ def session
220
+ execution_context[:session]
221
+ end
222
+
212
223
  # Add custom validation for required parameters
213
224
  validate do |_template|
214
225
  self.class.required_parameters.each do |param|