actionmcp 0.60.2 → 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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +46 -59
  3. data/app/controllers/action_mcp/application_controller.rb +95 -28
  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 +68 -43
  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/capability.rb +2 -0
  17. data/lib/action_mcp/client/json_rpc_handler.rb +9 -9
  18. data/lib/action_mcp/client/jwt_client_provider.rb +134 -0
  19. data/lib/action_mcp/configuration.rb +90 -11
  20. data/lib/action_mcp/engine.rb +26 -1
  21. data/lib/action_mcp/filtered_logger.rb +32 -0
  22. data/lib/action_mcp/oauth/active_record_storage.rb +183 -0
  23. data/lib/action_mcp/oauth/memory_storage.rb +23 -1
  24. data/lib/action_mcp/oauth/middleware.rb +33 -0
  25. data/lib/action_mcp/oauth/provider.rb +49 -13
  26. data/lib/action_mcp/oauth.rb +12 -0
  27. data/lib/action_mcp/prompt.rb +14 -0
  28. data/lib/action_mcp/registry_base.rb +25 -4
  29. data/lib/action_mcp/resource_response.rb +110 -0
  30. data/lib/action_mcp/resource_template.rb +30 -2
  31. data/lib/action_mcp/server/capabilities.rb +3 -14
  32. data/lib/action_mcp/server/memory_session.rb +0 -1
  33. data/lib/action_mcp/server/prompts.rb +8 -1
  34. data/lib/action_mcp/server/resources.rb +9 -6
  35. data/lib/action_mcp/server/tools.rb +41 -20
  36. data/lib/action_mcp/server.rb +6 -3
  37. data/lib/action_mcp/sse_listener.rb +0 -7
  38. data/lib/action_mcp/test_helper.rb +5 -0
  39. data/lib/action_mcp/tool.rb +108 -4
  40. data/lib/action_mcp/tools_registry.rb +3 -0
  41. data/lib/action_mcp/version.rb +1 -1
  42. data/lib/generators/action_mcp/install/templates/mcp.yml +16 -16
  43. data/lib/tasks/action_mcp_tasks.rake +238 -0
  44. metadata +11 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eee42f9c43bf618496aab880fc196bcd03620152d243de5570e85892e476372d
4
- data.tar.gz: 4a8d6beee9b55bc86df336f5f9c89d90f57fce562e9b051183d6aa657558db9b
3
+ metadata.gz: 83f504b6f7171acf10239a3873a3cf967243ed8fa598603b72ec3e265cf9ba7a
4
+ data.tar.gz: 4b50494bcf216f93243d2068c2d9f211c25a9fe5166fb01f21518321716a4d54
5
5
  SHA512:
6
- metadata.gz: 9b99efaeb97d77b110d5fa735e575af433b8a10d89820cd4c0260bae59c895662a95eb0df1fbec3fc89d6998946dec484412be80bcd594e056c8f3a8220c8ab3
7
- data.tar.gz: 311a451a50769764ae19bc0b3460f28a58ab99c68502e0aa856f000fc22de9796c9e4a6d9b51180d34940075e904dbeaed4a825e3d84e8513d2195f5a211da20
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
@@ -237,36 +242,18 @@ module Tron
237
242
  config.action_mcp.version = "1.2.3" # defaults to "0.0.1"
238
243
  config.action_mcp.logging_enabled = true # defaults to true
239
244
  config.action_mcp.logging_level = :info # defaults to :info, can be :debug, :info, :warn, :error, :fatal
240
- config.action_mcp.vibed_ignore_version = false # defaults to false, set to true to ignore client protocol version mismatches
241
245
  end
242
246
  end
243
247
  ```
244
248
 
245
249
  For dynamic versioning, consider adding the `rails_app_version` gem.
246
250
 
247
- ### Protocol Version Compatibility
248
-
249
- By default, ActionMCP requires clients to use the exact protocol version supported by the server (currently "2025-03-26"). If the client specifies a different version during initialization, the request will be rejected with an error.
250
-
251
- To support clients with incompatible protocol versions, you can enable the `vibed_ignore_version` option:
252
-
253
- ```ruby
254
- # In config/application.rb or an initializer
255
- Rails.application.config.action_mcp.vibed_ignore_version = true
256
- ```
257
-
258
- When enabled, the server will ignore protocol version mismatches from clients and always use the latest supported version. This is useful for:
259
- - Development environments with older client libraries
260
- - Supporting clients that cannot be easily updated
261
- - Situations where protocol differences are minor and known to be compatible
262
-
263
- > **Note:** Using `vibed_ignore_version = true` in production is not recommended as it may lead to unexpected behavior if clients rely on specific protocol features that differ between versions.
264
251
 
265
252
  ### PubSub Configuration
266
253
 
267
254
  ActionMCP uses a pub/sub system for real-time communication. You can choose between several adapters:
268
255
 
269
- 1. **SolidCable** - Database-backed pub/sub (no Redis required)
256
+ 1. **SolidMCP** - Database-backed pub/sub (no Redis required)
270
257
  2. **Simple** - In-memory pub/sub for development and testing
271
258
  3. **Redis** - Redis-backed pub/sub (if you prefer Redis)
272
259
 
@@ -275,7 +262,7 @@ ActionMCP uses a pub/sub system for real-time communication. You can choose betw
275
262
  If you were previously using ActionCable with ActionMCP, you will need to migrate to the new PubSub system. Here's how:
276
263
 
277
264
  1. Remove the ActionCable dependency from your Gemfile (if you don't need it for other purposes)
278
- 2. Install one of the PubSub adapters (SolidCable recommended)
265
+ 2. Install one of the PubSub adapters (SolidMCP recommended)
279
266
  3. Create a configuration file at `config/mcp.yml` (you can use the generator: `bin/rails g action_mcp:config`)
280
267
  4. Run your tests to ensure everything works correctly
281
268
 
@@ -285,7 +272,7 @@ Configure your adapter in `config/mcp.yml`:
285
272
 
286
273
  ```yaml
287
274
  development:
288
- adapter: solid_cable
275
+ adapter: solid_mcp
289
276
  polling_interval: 0.1.seconds
290
277
  # Thread pool configuration (optional)
291
278
  # min_threads: 5 # Minimum number of threads in the pool
@@ -296,10 +283,10 @@ test:
296
283
  adapter: test # Uses the simple in-memory adapter
297
284
 
298
285
  production:
299
- adapter: solid_cable
286
+ adapter: solid_mcp
300
287
  polling_interval: 0.5.seconds
301
288
  # Optional: connects_to: cable # If using a separate database
302
-
289
+
303
290
  # Thread pool configuration for high-traffic environments
304
291
  min_threads: 10 # Minimum number of threads in the pool
305
292
  max_threads: 20 # Maximum number of threads in the pool
@@ -339,7 +326,7 @@ production:
339
326
  adapter: redis
340
327
  url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
341
328
  channel_prefix: your_app_production
342
-
329
+
343
330
  # Thread pool configuration for high-traffic environments
344
331
  min_threads: 10 # Minimum number of threads in the pool
345
332
  max_threads: 20 # Maximum number of threads in the pool
@@ -391,7 +378,7 @@ session_store_type: volatile
391
378
  # Client-specific session store type (falls back to session_store_type if not specified)
392
379
  client_session_store_type: volatile
393
380
 
394
- # 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)
395
382
  server_session_store_type: active_record
396
383
  ```
397
384
 
@@ -488,7 +475,7 @@ You can configure the thread pool in your `config/mcp.yml`:
488
475
 
489
476
  ```yaml
490
477
  production:
491
- adapter: solid_cable
478
+ adapter: solid_mcp
492
479
  # Thread pool configuration
493
480
  min_threads: 10 # Minimum number of threads to keep in the pool
494
481
  max_threads: 20 # Maximum number of threads the pool can grow to
@@ -526,7 +513,7 @@ bin/rails generate action_mcp:install
526
513
 
527
514
  This will create:
528
515
  - `app/mcp/prompts/application_mcp_prompt.rb` - Base prompt class
529
- - `app/mcp/tools/application_mcp_tool.rb` - Base tool class
516
+ - `app/mcp/tools/application_mcp_tool.rb` - Base tool class
530
517
  - `app/mcp/resource_templates/application_mcp_res_template.rb` - Base resource template class
531
518
  - `app/mcp/application_gateway.rb` - Gateway for authentication
532
519
  - `config/mcp.yml` - Configuration file with example settings for all environments
@@ -557,7 +544,7 @@ class ApplicationGateway < ActionMCP::Gateway
557
544
 
558
545
  payload = ActionMCP::JwtDecoder.decode(token)
559
546
  user = resolve_user(payload)
560
-
547
+
561
548
  raise ActionMCP::UnauthorizedError, "Unauthorized" unless user
562
549
 
563
550
  # Return a hash with all identified_by attributes
@@ -580,13 +567,13 @@ You can identify connections by multiple attributes:
580
567
  ```ruby
581
568
  class ApplicationGateway < ActionMCP::Gateway
582
569
  identified_by :user, :organization
583
-
570
+
584
571
  protected
585
-
572
+
586
573
  def authenticate!
587
574
  # ... authentication logic ...
588
-
589
- {
575
+
576
+ {
590
577
  user: user,
591
578
  organization: user.organization
592
579
  }
@@ -654,7 +641,7 @@ Common middleware that can cause issues:
654
641
  - **Rack::Cors** - CORS headers meant for browsers
655
642
  - Any middleware assuming HTML responses or cookie-based authentication
656
643
 
657
- 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.
658
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.
659
646
  But remember to add any instrumentation or logging middleware you need, as the minimal setup will not include them by default.
660
647
 
@@ -662,7 +649,7 @@ But remember to add any instrumentation or logging middleware you need, as the m
662
649
 
663
650
  ## Production Deployment of MCPS0
664
651
 
665
- 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).
666
653
 
667
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.
668
655
 
@@ -722,7 +709,7 @@ First, install ActionMCP to create base classes and configuration:
722
709
 
723
710
  ```bash
724
711
  bin/rails action_mcp:install:migrations # to copy the migrations
725
- bin/rails generate action_mcp:install
712
+ bin/rails generate action_mcp:install
726
713
  ```
727
714
 
728
715
  This will create the base application classes, configuration file, and authentication gateway in your app directory.
@@ -12,6 +12,7 @@ module ActionMCP
12
12
  include ActionController::Live
13
13
  include ActionController::Instrumentation
14
14
 
15
+
15
16
  # Provides the ActionMCP::Session for the current request.
16
17
  # Handles finding existing sessions via header/param or initializing a new one.
17
18
  # Specific controllers/handlers might need to enforce session ID presence based on context.
@@ -46,8 +47,12 @@ module ActionMCP
46
47
  return render_not_found("Session has been terminated.")
47
48
  end
48
49
 
50
+ # Authenticate the request via gateway
51
+ authenticate_gateway!
52
+ return if performed?
53
+
49
54
  last_event_id = request.headers["Last-Event-ID"].presence
50
- 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
51
56
 
52
57
  response.headers["Content-Type"] = "text/event-stream"
53
58
  response.headers["X-Accel-Buffering"] = "no"
@@ -56,7 +61,7 @@ module ActionMCP
56
61
  # Add MCP-Protocol-Version header for established sessions
57
62
  response.headers["MCP-Protocol-Version"] = session.protocol_version
58
63
 
59
- 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
60
65
 
61
66
  sse = SSE.new(response.stream)
62
67
  listener = SSEListener.new(session)
@@ -80,12 +85,12 @@ module ActionMCP
80
85
  begin
81
86
  missed_events = session.get_sse_events_after(last_event_id.to_i)
82
87
  if missed_events.any?
83
- 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
84
89
  missed_events.each do |event|
85
90
  sse.write(event.to_sse)
86
91
  end
87
92
  else
88
- 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
89
94
  end
90
95
  rescue StandardError => e
91
96
  Rails.logger.error "Unified SSE (GET): Error sending missed events: #{e.message}"
@@ -111,7 +116,7 @@ module ActionMCP
111
116
  Rails.logger.warn "Unified SSE (GET): Heartbeat timed out for session: #{session.id}, closing."
112
117
  connection_active.make_false
113
118
  rescue StandardError => e
114
- 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
115
120
  connection_active.make_false
116
121
  end
117
122
  else
@@ -122,11 +127,11 @@ module ActionMCP
122
127
  heartbeat_task = Concurrent::ScheduledTask.execute(heartbeat_interval, &heartbeat_sender)
123
128
  sleep 0.1 while connection_active.true? && !response.stream.closed?
124
129
  rescue ActionController::Live::ClientDisconnected, IOError => e
125
- 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
126
131
  rescue StandardError => e
127
132
  Rails.logger.error "Unified SSE (GET): Unexpected error for session: #{session&.id}: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}"
128
133
  ensure
129
- 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
130
135
  heartbeat_active&.make_false
131
136
  heartbeat_task&.cancel
132
137
  listener&.stop
@@ -143,7 +148,7 @@ module ActionMCP
143
148
  # @route POST /mcp
144
149
  def create
145
150
  unless post_accept_headers_valid?
146
- id = extract_jsonrpc_id_from_params
151
+ id = extract_jsonrpc_id_from_request
147
152
  return render_not_acceptable(post_accept_headers_error_message, id)
148
153
  end
149
154
 
@@ -157,10 +162,9 @@ module ActionMCP
157
162
  session = mcp_session
158
163
 
159
164
  # Validate MCP-Protocol-Version header for non-initialize requests
160
- # Temporarily disabled to debug session issues
161
- # return unless validate_protocol_version_header
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,24 +238,32 @@ 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
- header_version = request.headers["MCP-Protocol-Version"]
247
+ # Check for both case variations of the header (spec uses MCP-Protocol-Version)
248
+ header_version = request.headers["MCP-Protocol-Version"] || request.headers["mcp-protocol-version"]
232
249
  session = mcp_session
233
250
 
234
- # If header is missing, assume 2025-03-26 for backward compatibility
251
+ # If header is missing, assume 2025-03-26 for backward compatibility as per spec
235
252
  if header_version.nil?
236
- Rails.logger.debug "MCP-Protocol-Version header missing, assuming 2025-03-26 for backward compatibility"
253
+ ActionMCP.logger.debug "MCP-Protocol-Version header missing, assuming 2025-03-26 for backward compatibility"
237
254
  return true
238
255
  end
239
256
 
257
+ # Handle array values (take the last one as per TypeScript SDK)
258
+ if header_version.is_a?(Array)
259
+ header_version = header_version.last
260
+ end
261
+
240
262
  # Check if the header version is supported
241
263
  unless ActionMCP::SUPPORTED_VERSIONS.include?(header_version)
242
- render_bad_request("Unsupported MCP-Protocol-Version: #{header_version}")
264
+ supported_versions = ActionMCP::SUPPORTED_VERSIONS.join(", ")
265
+ ActionMCP.logger.warn "Unsupported MCP-Protocol-Version: #{header_version}. Supported versions: #{supported_versions}"
266
+ render_protocol_version_error("Unsupported MCP-Protocol-Version: #{header_version}. Supported versions: #{supported_versions}")
243
267
  return false
244
268
  end
245
269
 
@@ -247,12 +271,13 @@ module ActionMCP
247
271
  if session && session.initialized?
248
272
  negotiated_version = session.protocol_version
249
273
  if header_version != negotiated_version
250
- Rails.logger.warn "MCP-Protocol-Version mismatch: header=#{header_version}, negotiated=#{negotiated_version}"
251
- render_bad_request("MCP-Protocol-Version header (#{header_version}) does not match negotiated version (#{negotiated_version})")
274
+ ActionMCP.logger.warn "MCP-Protocol-Version mismatch: header=#{header_version}, negotiated=#{negotiated_version}"
275
+ render_protocol_version_error("MCP-Protocol-Version header (#{header_version}) does not match negotiated version (#{negotiated_version})")
252
276
  return false
253
277
  end
254
278
  end
255
279
 
280
+ ActionMCP.logger.debug "MCP-Protocol-Version header validation passed: #{header_version}"
256
281
  true
257
282
  end
258
283
 
@@ -260,12 +285,12 @@ module ActionMCP
260
285
  # Note: This doesn't save the new session; that happens upon first use or explicitly.
261
286
  def find_or_initialize_session
262
287
  session_id = extract_session_id
288
+ session_store = ActionMCP::Server.session_store
289
+
263
290
  if session_id
264
- session = Server.session_store.load_session(session_id)
265
- # Session protocol version is set during initialization and should not be overridden
266
- session
291
+ session_store.load_session(session_id)
267
292
  else
268
- Server.session_store.create_session(nil, protocol_version: ActionMCP::DEFAULT_PROTOCOL_VERSION)
293
+ session_store.create_session(nil, protocol_version: ActionMCP::DEFAULT_PROTOCOL_VERSION)
269
294
  end
270
295
  end
271
296
 
@@ -305,6 +330,13 @@ module ActionMCP
305
330
  payload.method == "initialize"
306
331
  end
307
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
+
308
340
  # Processes the results from the JsonRpcHandler.
309
341
  def process_handler_results(result, session, session_initially_missing, is_initialize_request)
310
342
  # Handle empty result (notifications)
@@ -364,7 +396,7 @@ module ActionMCP
364
396
  rescue StandardError
365
397
  nil
366
398
  end
367
- Rails.logger.debug "Unified SSE (POST): Response stream closed."
399
+ Rails.logger.debug "Unified SSE (POST): Response stream closed." if ActionMCP.configuration.verbose_logging
368
400
  end
369
401
 
370
402
  # Helper to write a JSON payload as an SSE event with a unique ID.
@@ -396,7 +428,7 @@ module ActionMCP
396
428
  begin
397
429
  retention_period = session.sse_event_retention_period
398
430
  count = session.cleanup_old_sse_events(retention_period)
399
- 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
400
432
  rescue StandardError => e
401
433
  Rails.logger.error "Error cleaning up old SSE events: #{e.message}"
402
434
  end
@@ -415,6 +447,12 @@ module ActionMCP
415
447
  render json: { jsonrpc: "2.0", id: id, error: { code: -32_600, message: message } }
416
448
  end
417
449
 
450
+ # Renders a 400 Bad Request response for protocol version errors as per MCP spec
451
+ def render_protocol_version_error(message = "Protocol Version Error", id = nil)
452
+ id ||= extract_jsonrpc_id_from_request
453
+ render json: { jsonrpc: "2.0", id: id, error: { code: -32_000, message: message } }, status: :bad_request
454
+ end
455
+
418
456
  # Renders a 404 Not Found response with a JSON-RPC-like error structure.
419
457
  def render_not_found(message = "Not Found", id = nil)
420
458
  id ||= extract_jsonrpc_id_from_request
@@ -463,5 +501,34 @@ module ActionMCP
463
501
  nil
464
502
  end
465
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
466
533
  end
467
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