actionmcp 0.70.0 → 0.71.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 +46 -41
  3. data/app/controllers/action_mcp/application_controller.rb +67 -15
  4. data/app/controllers/action_mcp/oauth/metadata_controller.rb +13 -13
  5. data/app/controllers/action_mcp/oauth/registration_controller.rb +206 -0
  6. data/app/models/action_mcp/oauth_client.rb +157 -0
  7. data/app/models/action_mcp/oauth_token.rb +141 -0
  8. data/app/models/action_mcp/session/message.rb +12 -12
  9. data/app/models/action_mcp/session/resource.rb +2 -2
  10. data/app/models/action_mcp/session/sse_event.rb +2 -2
  11. data/app/models/action_mcp/session/subscription.rb +2 -2
  12. data/app/models/action_mcp/session.rb +22 -22
  13. data/config/routes.rb +1 -0
  14. data/db/migrate/20250708105124_create_action_mcp_oauth_clients.rb +42 -0
  15. data/db/migrate/20250708105226_create_action_mcp_oauth_tokens.rb +37 -0
  16. data/lib/action_mcp/client/jwt_client_provider.rb +134 -0
  17. data/lib/action_mcp/configuration.rb +27 -4
  18. data/lib/action_mcp/filtered_logger.rb +32 -0
  19. data/lib/action_mcp/oauth/active_record_storage.rb +183 -0
  20. data/lib/action_mcp/oauth/memory_storage.rb +23 -1
  21. data/lib/action_mcp/oauth/middleware.rb +33 -0
  22. data/lib/action_mcp/oauth/provider.rb +49 -13
  23. data/lib/action_mcp/oauth.rb +12 -0
  24. data/lib/action_mcp/server/capabilities.rb +0 -3
  25. data/lib/action_mcp/server/resources.rb +1 -1
  26. data/lib/action_mcp/server/tools.rb +36 -24
  27. data/lib/action_mcp/sse_listener.rb +0 -7
  28. data/lib/action_mcp/test_helper.rb +5 -0
  29. data/lib/action_mcp/tool.rb +94 -4
  30. data/lib/action_mcp/tools_registry.rb +3 -0
  31. data/lib/action_mcp/version.rb +1 -1
  32. data/lib/generators/action_mcp/install/templates/mcp.yml +16 -16
  33. metadata +10 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b498d3bdd7cde670eef99ba3458e4af3808ce1eaf2cb29c369bdaa3048a9098
4
- data.tar.gz: a3f0f8db133018b5f9a2ee822f35a59c0cbf79301be7b69c894ef4daa2f99472
3
+ metadata.gz: 83f504b6f7171acf10239a3873a3cf967243ed8fa598603b72ec3e265cf9ba7a
4
+ data.tar.gz: 4b50494bcf216f93243d2068c2d9f211c25a9fe5166fb01f21518321716a4d54
5
5
  SHA512:
6
- metadata.gz: 4880debf4726112a664348076862e92c92578c9d4cafd6a0ac923944e37d5c6929bab0b3779bcc322fd478b19f57635f629be76100e9b3254b4fd3895c3eff13
7
- data.tar.gz: dba72df2677959588c73431c99cd7a3a3d176ac3fcbfea99d5ea2c53e2ff96aac228a5a46d5f3dc8c7b1f79a9f2cb1467d0a784236d613deeddb135ddcf300bd
6
+ metadata.gz: e2f3c857fdfd8404660d508ff620500676c71f629b5ac0a7ab0e7190248d25ea81d15eef679cbb14cba5f15ff119e882502de28d830f6c67a10c8ee7254a4301
7
+ data.tar.gz: 3d44ee314a37df260fbc00c5d7488330f0d0e5e637c5b200a784bf62301b2feb7b330796cd2ff0268a96de70f498d946983aa89c745e6e636e915b37f5d81587
data/README.md CHANGED
@@ -30,7 +30,7 @@ ActionMCP supports **MCP 2025-06-18** (current) with backward compatibility for
30
30
 
31
31
  > **Note:** STDIO transport is not supported in ActionMCP. This gem is focused on production-ready, network-based deployments. STDIO is only suitable for desktop or script-based experimentation and is intentionally excluded.
32
32
 
33
- Instead of implementing MCP support from scratch, you can subclass and configure the provided **Prompt**, **Tool**, and **ResourceTemplate** classes to expose your app's functionality to LLMs.
33
+ Instead of implementing MCP support from scratch, you can subclass and configure the provided **Prompt**, **Tool**, and **ResourceTemplate** classes to expose your app's functionality to LLMs.
34
34
 
35
35
  ActionMCP handles the underlying MCP message format and routing, so you can adhere to the open standard with minimal effort.
36
36
 
@@ -52,12 +52,17 @@ After adding the gem, run the install generator to set up the basic ActionMCP st
52
52
 
53
53
  ```bash
54
54
  bundle install
55
- bin/rails action_mcp:install:migrations
56
- bin/rails db:migrate
57
- bin/rails generate action_mcp:install
55
+ bin/rails action_mcp:install:migrations # Copy migrations from the engine
56
+ bin/rails generate action_mcp:install # Creates base classes and configuration
57
+ bin/rails db:migrate # Creates necessary database tables
58
58
  ```
59
59
 
60
- This will create the base application classes, configuration file, and necessary database tables for ActionMCP to function properly.
60
+ The `action_mcp:install` generator will:
61
+ - Create base application classes (ApplicationGateway, ApplicationMCPTool, etc.)
62
+ - Generate the MCP configuration file (`config/mcp.yml`)
63
+ - Set up the basic directory structure for MCP components
64
+
65
+ Database migrations are copied separately using `bin/rails action_mcp:install:migrations`.
61
66
 
62
67
  ## Core Components
63
68
 
@@ -88,18 +93,18 @@ class AnalyzeCodePrompt < ApplicationMCPPrompt
88
93
  def perform
89
94
  render(text: "Please analyze this #{language} code for improvements:")
90
95
  render(text: code)
91
-
96
+
92
97
  # You can add assistant messages too
93
98
  render(text: "Here are some things to focus on in your analysis:", role: :assistant)
94
-
99
+
95
100
  # Even add resources if needed
96
- render(resource: "file://documentation/#{language.downcase}_style_guide.pdf",
97
- mime_type: "application/pdf",
101
+ render(resource: "file://documentation/#{language.downcase}_style_guide.pdf",
102
+ mime_type: "application/pdf",
98
103
  blob: get_style_guide_pdf(language))
99
104
  end
100
-
105
+
101
106
  private
102
-
107
+
103
108
  def get_style_guide_pdf(language)
104
109
  # Implementation to retrieve style guide as base64
105
110
  end
@@ -130,25 +135,25 @@ class CalculateSumTool < ApplicationMCPTool
130
135
  tool_name "calculate_sum"
131
136
  description "Calculate the sum of two numbers"
132
137
 
133
- property :a, type: "number", description: "First number", required: true
134
- property :b, type: "number", description: "Second number", required: true
135
-
138
+ property :a, type: "number", description: "The first number", required: true
139
+ property :b, type: "number", description: "The second number", required: true
140
+
136
141
  def perform
137
142
  sum = a + b
138
143
  render(text: "Calculating #{a} + #{b}...")
139
144
  render(text: "The sum is #{sum}")
140
-
145
+
141
146
  # You can render errors if needed
142
147
  if sum > 1000
143
148
  render(error: ["Warning: Sum exceeds recommended limit"])
144
149
  end
145
-
150
+
146
151
  # Or even images
147
152
  render(image: generate_visualization(a, b), mime_type: "image/png")
148
153
  end
149
-
154
+
150
155
  private
151
-
156
+
152
157
  def generate_visualization(a, b)
153
158
  # Implementation to create a visualization as base64
154
159
  end
@@ -164,7 +169,7 @@ result = sum_tool.call
164
169
 
165
170
  ### ActionMCP::ResourceTemplate
166
171
 
167
- `ActionMCP::ResourceTemplate` facilitates the creation of URI templates for dynamic resources that LLMs can access.
172
+ `ActionMCP::ResourceTemplate` facilitates the creation of URI templates for dynamic resources that LLMs can access.
168
173
  This allows models to request specific data using parameterized URIs.
169
174
 
170
175
  **Example:**
@@ -197,23 +202,23 @@ end
197
202
 
198
203
  ```ruby
199
204
  before_resolve do |template|
200
- logger.tagged("ProductsTemplate") { logger.info("Starting to resolve product: #{template.product_id}") }
205
+ # Starting to resolve product: #{template.product_id}
201
206
  end
202
207
 
203
208
  after_resolve do |template|
204
- logger.tagged("ProductsTemplate") { logger.info("Finished resolving product resource for product: #{template.product_id}") }
209
+ # Finished resolving product resource for product: #{template.product_id}
205
210
  end
206
211
 
207
212
  around_resolve do |template, block|
208
213
  start_time = Time.current
209
- logger.tagged("ProductsTemplate") { logger.info("Starting resolution for product: #{template.product_id}") }
214
+ # Starting resolution for product: #{template.product_id}
210
215
 
211
216
  resource = block.call
212
217
 
213
218
  if resource
214
- logger.tagged("ProductsTemplate") { logger.info("Product #{template.product_id} resolved successfully in #{Time.current - start_time}s") }
219
+ # Product #{template.product_id} resolved successfully in #{Time.current - start_time}s
215
220
  else
216
- logger.tagged("ProductsTemplate") { logger.info("Product #{template.product_id} not found") }
221
+ # Product #{template.product_id} not found
217
222
  end
218
223
 
219
224
  resource
@@ -248,7 +253,7 @@ For dynamic versioning, consider adding the `rails_app_version` gem.
248
253
 
249
254
  ActionMCP uses a pub/sub system for real-time communication. You can choose between several adapters:
250
255
 
251
- 1. **SolidCable** - Database-backed pub/sub (no Redis required)
256
+ 1. **SolidMCP** - Database-backed pub/sub (no Redis required)
252
257
  2. **Simple** - In-memory pub/sub for development and testing
253
258
  3. **Redis** - Redis-backed pub/sub (if you prefer Redis)
254
259
 
@@ -257,7 +262,7 @@ ActionMCP uses a pub/sub system for real-time communication. You can choose betw
257
262
  If you were previously using ActionCable with ActionMCP, you will need to migrate to the new PubSub system. Here's how:
258
263
 
259
264
  1. Remove the ActionCable dependency from your Gemfile (if you don't need it for other purposes)
260
- 2. Install one of the PubSub adapters (SolidCable recommended)
265
+ 2. Install one of the PubSub adapters (SolidMCP recommended)
261
266
  3. Create a configuration file at `config/mcp.yml` (you can use the generator: `bin/rails g action_mcp:config`)
262
267
  4. Run your tests to ensure everything works correctly
263
268
 
@@ -267,7 +272,7 @@ Configure your adapter in `config/mcp.yml`:
267
272
 
268
273
  ```yaml
269
274
  development:
270
- adapter: solid_cable
275
+ adapter: solid_mcp
271
276
  polling_interval: 0.1.seconds
272
277
  # Thread pool configuration (optional)
273
278
  # min_threads: 5 # Minimum number of threads in the pool
@@ -278,10 +283,10 @@ test:
278
283
  adapter: test # Uses the simple in-memory adapter
279
284
 
280
285
  production:
281
- adapter: solid_cable
286
+ adapter: solid_mcp
282
287
  polling_interval: 0.5.seconds
283
288
  # Optional: connects_to: cable # If using a separate database
284
-
289
+
285
290
  # Thread pool configuration for high-traffic environments
286
291
  min_threads: 10 # Minimum number of threads in the pool
287
292
  max_threads: 20 # Maximum number of threads in the pool
@@ -321,7 +326,7 @@ production:
321
326
  adapter: redis
322
327
  url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
323
328
  channel_prefix: your_app_production
324
-
329
+
325
330
  # Thread pool configuration for high-traffic environments
326
331
  min_threads: 10 # Minimum number of threads in the pool
327
332
  max_threads: 20 # Maximum number of threads in the pool
@@ -373,7 +378,7 @@ session_store_type: volatile
373
378
  # Client-specific session store type (falls back to session_store_type if not specified)
374
379
  client_session_store_type: volatile
375
380
 
376
- # Server-specific session store type (falls back to session_store_type if not specified)
381
+ # Server-specific session store type (falls back to session_store_type if not specified)
377
382
  server_session_store_type: active_record
378
383
  ```
379
384
 
@@ -470,7 +475,7 @@ You can configure the thread pool in your `config/mcp.yml`:
470
475
 
471
476
  ```yaml
472
477
  production:
473
- adapter: solid_cable
478
+ adapter: solid_mcp
474
479
  # Thread pool configuration
475
480
  min_threads: 10 # Minimum number of threads to keep in the pool
476
481
  max_threads: 20 # Maximum number of threads the pool can grow to
@@ -508,7 +513,7 @@ bin/rails generate action_mcp:install
508
513
 
509
514
  This will create:
510
515
  - `app/mcp/prompts/application_mcp_prompt.rb` - Base prompt class
511
- - `app/mcp/tools/application_mcp_tool.rb` - Base tool class
516
+ - `app/mcp/tools/application_mcp_tool.rb` - Base tool class
512
517
  - `app/mcp/resource_templates/application_mcp_res_template.rb` - Base resource template class
513
518
  - `app/mcp/application_gateway.rb` - Gateway for authentication
514
519
  - `config/mcp.yml` - Configuration file with example settings for all environments
@@ -539,7 +544,7 @@ class ApplicationGateway < ActionMCP::Gateway
539
544
 
540
545
  payload = ActionMCP::JwtDecoder.decode(token)
541
546
  user = resolve_user(payload)
542
-
547
+
543
548
  raise ActionMCP::UnauthorizedError, "Unauthorized" unless user
544
549
 
545
550
  # Return a hash with all identified_by attributes
@@ -562,13 +567,13 @@ You can identify connections by multiple attributes:
562
567
  ```ruby
563
568
  class ApplicationGateway < ActionMCP::Gateway
564
569
  identified_by :user, :organization
565
-
570
+
566
571
  protected
567
-
572
+
568
573
  def authenticate!
569
574
  # ... authentication logic ...
570
-
571
- {
575
+
576
+ {
572
577
  user: user,
573
578
  organization: user.organization
574
579
  }
@@ -636,7 +641,7 @@ Common middleware that can cause issues:
636
641
  - **Rack::Cors** - CORS headers meant for browsers
637
642
  - Any middleware assuming HTML responses or cookie-based authentication
638
643
 
639
- An example of a minimal `mcp_vanilla.ru` file is located in the dummy app : test/dummy/mcp_vanilla.ru.
644
+ An example of a minimal `mcp_vanilla.ru` file is located in the dummy app : test/dummy/mcp_vanilla.ru.
640
645
  This file is a minimal Rack application that only includes the essential middleware needed for MCP server operation, avoiding conflicts with web-specific middleware.
641
646
  But remember to add any instrumentation or logging middleware you need, as the minimal setup will not include them by default.
642
647
 
@@ -644,7 +649,7 @@ But remember to add any instrumentation or logging middleware you need, as the m
644
649
 
645
650
  ## Production Deployment of MCPS0
646
651
 
647
- In production, **MCPS0** (the MCP server) is a standard Rack application. You can run it using any Rack-compatible server (such as Puma, Unicorn, or Passenger).
652
+ In production, **MCPS0** (the MCP server) is a standard Rack application. You can run it using any Rack-compatible server (such as Puma, Unicorn, or Passenger).
648
653
 
649
654
  > **For best performance and concurrency, it is highly recommended to use a modern, synchronous server like [Falcon](https://github.com/socketry/falcon)**. Falcon is optimized for streaming and concurrent workloads, making it ideal for MCP servers. You can still use Puma, Unicorn, or Passenger, but Falcon will generally provide superior throughput and responsiveness for real-time and streaming use cases.
650
655
 
@@ -704,7 +709,7 @@ First, install ActionMCP to create base classes and configuration:
704
709
 
705
710
  ```bash
706
711
  bin/rails action_mcp:install:migrations # to copy the migrations
707
- bin/rails generate action_mcp:install
712
+ bin/rails generate action_mcp:install
708
713
  ```
709
714
 
710
715
  This will create the base application classes, configuration file, and authentication gateway in your app directory.
@@ -47,8 +47,12 @@ module ActionMCP
47
47
  return render_not_found("Session has been terminated.")
48
48
  end
49
49
 
50
+ # Authenticate the request via gateway
51
+ authenticate_gateway!
52
+ return if performed?
53
+
50
54
  last_event_id = request.headers["Last-Event-ID"].presence
51
- Rails.logger.info "Unified SSE (GET): Resuming from Last-Event-ID: #{last_event_id}" if last_event_id
55
+ Rails.logger.info "Unified SSE (GET): Resuming from Last-Event-ID: #{last_event_id}" if last_event_id && ActionMCP.configuration.verbose_logging
52
56
 
53
57
  response.headers["Content-Type"] = "text/event-stream"
54
58
  response.headers["X-Accel-Buffering"] = "no"
@@ -57,7 +61,7 @@ module ActionMCP
57
61
  # Add MCP-Protocol-Version header for established sessions
58
62
  response.headers["MCP-Protocol-Version"] = session.protocol_version
59
63
 
60
- Rails.logger.info "Unified SSE (GET): Starting stream for session: #{session.id}"
64
+ Rails.logger.info "Unified SSE (GET): Starting stream for session: #{session.id}" if ActionMCP.configuration.verbose_logging
61
65
 
62
66
  sse = SSE.new(response.stream)
63
67
  listener = SSEListener.new(session)
@@ -81,12 +85,12 @@ module ActionMCP
81
85
  begin
82
86
  missed_events = session.get_sse_events_after(last_event_id.to_i)
83
87
  if missed_events.any?
84
- Rails.logger.info "Unified SSE (GET): Sending #{missed_events.size} missed events for session: #{session.id}"
88
+ Rails.logger.info "Unified SSE (GET): Sending #{missed_events.size} missed events for session: #{session.id}" if ActionMCP.configuration.verbose_logging
85
89
  missed_events.each do |event|
86
90
  sse.write(event.to_sse)
87
91
  end
88
92
  else
89
- Rails.logger.info "Unified SSE (GET): No missed events to send for session: #{session.id}"
93
+ Rails.logger.info "Unified SSE (GET): No missed events to send for session: #{session.id}" if ActionMCP.configuration.verbose_logging
90
94
  end
91
95
  rescue StandardError => e
92
96
  Rails.logger.error "Unified SSE (GET): Error sending missed events: #{e.message}"
@@ -112,7 +116,7 @@ module ActionMCP
112
116
  Rails.logger.warn "Unified SSE (GET): Heartbeat timed out for session: #{session.id}, closing."
113
117
  connection_active.make_false
114
118
  rescue StandardError => e
115
- Rails.logger.debug "Unified SSE (GET): Heartbeat error for session: #{session.id}: #{e.message}"
119
+ Rails.logger.debug "Unified SSE (GET): Heartbeat error for session: #{session.id}: #{e.message}" if ActionMCP.configuration.verbose_logging
116
120
  connection_active.make_false
117
121
  end
118
122
  else
@@ -123,11 +127,11 @@ module ActionMCP
123
127
  heartbeat_task = Concurrent::ScheduledTask.execute(heartbeat_interval, &heartbeat_sender)
124
128
  sleep 0.1 while connection_active.true? && !response.stream.closed?
125
129
  rescue ActionController::Live::ClientDisconnected, IOError => e
126
- Rails.logger.debug "Unified SSE (GET): Client disconnected for session: #{session&.id}: #{e.message}"
130
+ Rails.logger.debug "Unified SSE (GET): Client disconnected for session: #{session&.id}: #{e.message}" if ActionMCP.configuration.verbose_logging
127
131
  rescue StandardError => e
128
132
  Rails.logger.error "Unified SSE (GET): Unexpected error for session: #{session&.id}: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}"
129
133
  ensure
130
- Rails.logger.debug "Unified SSE (GET): Cleaning up connection for session: #{session&.id}"
134
+ Rails.logger.debug "Unified SSE (GET): Cleaning up connection for session: #{session&.id}" if ActionMCP.configuration.verbose_logging
131
135
  heartbeat_active&.make_false
132
136
  heartbeat_task&.cancel
133
137
  listener&.stop
@@ -160,7 +164,7 @@ module ActionMCP
160
164
  # Validate MCP-Protocol-Version header for non-initialize requests
161
165
  return unless validate_protocol_version_header
162
166
 
163
- unless is_initialize_request
167
+ unless initialization_related_request?(jsonrpc_params)
164
168
  if session_initially_missing
165
169
  id = jsonrpc_params.respond_to?(:id) ? jsonrpc_params.id : nil
166
170
  return render_bad_request("Mcp-Session-Id header is required for this request.", id)
@@ -178,6 +182,14 @@ module ActionMCP
178
182
  response.headers[MCP_SESSION_ID_HEADER] = session.id
179
183
  end
180
184
 
185
+ # Authenticate the request via gateway (skipped for initialization-related requests)
186
+ if initialization_related_request?(jsonrpc_params)
187
+ # Skipping authentication for initialization request: #{jsonrpc_params.method}
188
+ else
189
+ authenticate_gateway!
190
+ return if performed?
191
+ end
192
+
181
193
  # Use return mode for the transport handler when we need to capture responses
182
194
  transport_handler = Server::TransportHandler.new(session, messaging_mode: :return)
183
195
  json_rpc_handler = Server::JsonRpcHandler.new(transport_handler)
@@ -185,7 +197,7 @@ module ActionMCP
185
197
  result = json_rpc_handler.call(jsonrpc_params)
186
198
  process_handler_results(result, session, session_initially_missing, is_initialize_request)
187
199
  rescue ActionController::Live::ClientDisconnected, IOError => e
188
- Rails.logger.debug "Unified SSE (POST): Client disconnected during response: #{e.message}"
200
+ Rails.logger.debug "Unified SSE (POST): Client disconnected during response: #{e.message}" if ActionMCP.configuration.verbose_logging
189
201
  begin
190
202
  response.stream&.close
191
203
  rescue StandardError
@@ -210,9 +222,13 @@ module ActionMCP
210
222
  return head :no_content
211
223
  end
212
224
 
225
+ # Authenticate the request via gateway
226
+ authenticate_gateway!
227
+ return if performed?
228
+
213
229
  begin
214
230
  session.close!
215
- Rails.logger.info "Unified DELETE: Terminated session: #{session.id}"
231
+ Rails.logger.info "Unified DELETE: Terminated session: #{session.id}" if ActionMCP.configuration.verbose_logging
216
232
  head :no_content
217
233
  rescue StandardError => e
218
234
  Rails.logger.error "Unified DELETE: Error terminating session #{session.id}: #{e.class} - #{e.message}"
@@ -222,11 +238,11 @@ module ActionMCP
222
238
 
223
239
  private
224
240
 
225
- # Validates the MCP-Protocol-Version header for non-initialize requests
241
+ # Validates the MCP-Protocol-Version header for non-initialization requests
226
242
  # Returns true if valid, renders error and returns false if invalid
227
243
  def validate_protocol_version_header
228
- # Skip validation for initialize requests
229
- return true if check_if_initialize_request(jsonrpc_params)
244
+ # Skip validation for initialization-related requests
245
+ return true if initialization_related_request?(jsonrpc_params)
230
246
 
231
247
  # Check for both case variations of the header (spec uses MCP-Protocol-Version)
232
248
  header_version = request.headers["MCP-Protocol-Version"] || request.headers["mcp-protocol-version"]
@@ -314,6 +330,13 @@ module ActionMCP
314
330
  payload.method == "initialize"
315
331
  end
316
332
 
333
+ # Checks if the request is related to initialization (initialize or notifications/initialized)
334
+ def initialization_related_request?(payload)
335
+ return false unless payload.respond_to?(:method) && !jsonrpc_params_batch?
336
+
337
+ %w[initialize notifications/initialized].include?(payload.method)
338
+ end
339
+
317
340
  # Processes the results from the JsonRpcHandler.
318
341
  def process_handler_results(result, session, session_initially_missing, is_initialize_request)
319
342
  # Handle empty result (notifications)
@@ -373,7 +396,7 @@ module ActionMCP
373
396
  rescue StandardError
374
397
  nil
375
398
  end
376
- Rails.logger.debug "Unified SSE (POST): Response stream closed."
399
+ Rails.logger.debug "Unified SSE (POST): Response stream closed." if ActionMCP.configuration.verbose_logging
377
400
  end
378
401
 
379
402
  # Helper to write a JSON payload as an SSE event with a unique ID.
@@ -405,7 +428,7 @@ module ActionMCP
405
428
  begin
406
429
  retention_period = session.sse_event_retention_period
407
430
  count = session.cleanup_old_sse_events(retention_period)
408
- Rails.logger.debug "Cleaned up #{count} old SSE events for session: #{session.id}" if count.positive?
431
+ Rails.logger.debug "Cleaned up #{count} old SSE events for session: #{session.id}" if count.positive? && ActionMCP.configuration.verbose_logging
409
432
  rescue StandardError => e
410
433
  Rails.logger.error "Error cleaning up old SSE events: #{e.message}"
411
434
  end
@@ -478,5 +501,34 @@ module ActionMCP
478
501
  nil
479
502
  end
480
503
  end
504
+
505
+ # Authenticates the request using the configured gateway
506
+ def authenticate_gateway!
507
+ # Skip authentication for initialization-related requests in POST method
508
+ if request.post? && defined?(jsonrpc_params) && jsonrpc_params
509
+ return if initialization_related_request?(jsonrpc_params)
510
+ end
511
+
512
+ gateway_class = ActionMCP.configuration.gateway_class
513
+ return unless gateway_class # Skip if no gateway configured
514
+
515
+ gateway = gateway_class.new
516
+ gateway.call(request)
517
+ rescue ActionMCP::UnauthorizedError => e
518
+ render_unauthorized(e.message)
519
+ end
520
+
521
+ # Renders an unauthorized response
522
+ def render_unauthorized(message = "Unauthorized", id = nil)
523
+ id ||= extract_jsonrpc_id_from_request
524
+
525
+ # Add WWW-Authenticate header for OAuth discovery as per spec
526
+ auth_methods = ActionMCP.configuration.authentication_methods || []
527
+ if auth_methods.include?("oauth")
528
+ response.headers["WWW-Authenticate"] = 'Bearer realm="MCP API"'
529
+ end
530
+
531
+ render json: { jsonrpc: "2.0", id: id, error: { code: -32_000, message: message } }, status: :unauthorized
532
+ end
481
533
  end
482
534
  end
@@ -25,12 +25,12 @@ module ActionMCP
25
25
  }
26
26
 
27
27
  # Add optional fields based on configuration
28
- if oauth_config["enable_dynamic_registration"]
28
+ if oauth_config[:enable_dynamic_registration]
29
29
  metadata[:registration_endpoint] = registration_endpoint
30
30
  end
31
31
 
32
- if oauth_config["jwks_uri"]
33
- metadata[:jwks_uri] = oauth_config["jwks_uri"]
32
+ if oauth_config[:jwks_uri]
33
+ metadata[:jwks_uri] = oauth_config[:jwks_uri]
34
34
  end
35
35
 
36
36
  render json: metadata
@@ -60,11 +60,11 @@ module ActionMCP
60
60
  end
61
61
 
62
62
  def oauth_config
63
- @oauth_config ||= ActionMCP.configuration.oauth_config || {}
63
+ @oauth_config ||= HashWithIndifferentAccess.new(ActionMCP.configuration.oauth_config || {})
64
64
  end
65
65
 
66
66
  def issuer_url
67
- @issuer_url ||= oauth_config["issuer_url"] || request.base_url
67
+ @issuer_url ||= oauth_config.fetch(:issuer_url, request.base_url)
68
68
  end
69
69
 
70
70
  def authorization_endpoint
@@ -93,36 +93,36 @@ module ActionMCP
93
93
 
94
94
  def grant_types_supported
95
95
  grants = [ "authorization_code" ]
96
- grants << "refresh_token" if oauth_config["enable_refresh_tokens"]
97
- grants << "client_credentials" if oauth_config["enable_client_credentials"]
96
+ grants << "refresh_token" if oauth_config[:enable_refresh_tokens]
97
+ grants << "client_credentials" if oauth_config[:enable_client_credentials]
98
98
  grants
99
99
  end
100
100
 
101
101
  def token_endpoint_auth_methods_supported
102
102
  methods = [ "client_secret_basic", "client_secret_post" ]
103
- methods << "none" if oauth_config["allow_public_clients"]
103
+ methods << "none" if oauth_config[:allow_public_clients]
104
104
  methods
105
105
  end
106
106
 
107
107
  def scopes_supported
108
- oauth_config["scopes_supported"] || [ "mcp:tools", "mcp:resources", "mcp:prompts" ]
108
+ oauth_config.fetch(:scopes_supported, [ "mcp:tools", "mcp:resources", "mcp:prompts" ])
109
109
  end
110
110
 
111
111
  def code_challenge_methods_supported
112
112
  methods = []
113
- if oauth_config["pkce_required"] || oauth_config["pkce_supported"]
113
+ if oauth_config[:pkce_required] || oauth_config[:pkce_supported]
114
114
  methods << "S256"
115
- methods << "plain" if oauth_config["allow_plain_pkce"]
115
+ methods << "plain" if oauth_config[:allow_plain_pkce]
116
116
  end
117
117
  methods
118
118
  end
119
119
 
120
120
  def service_documentation
121
- oauth_config["service_documentation"] || "#{request.base_url}/docs"
121
+ oauth_config.fetch(:service_documentation, "#{request.base_url}/docs")
122
122
  end
123
123
 
124
124
  def resource_documentation
125
- oauth_config["resource_documentation"] || "#{request.base_url}/docs/api"
125
+ oauth_config.fetch(:resource_documentation, "#{request.base_url}/docs/api")
126
126
  end
127
127
  end
128
128
  end