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.
- checksums.yaml +4 -4
- data/README.md +46 -59
- data/app/controllers/action_mcp/application_controller.rb +95 -28
- data/app/controllers/action_mcp/oauth/metadata_controller.rb +13 -13
- data/app/controllers/action_mcp/oauth/registration_controller.rb +206 -0
- data/app/models/action_mcp/oauth_client.rb +157 -0
- data/app/models/action_mcp/oauth_token.rb +141 -0
- data/app/models/action_mcp/session/message.rb +12 -12
- data/app/models/action_mcp/session/resource.rb +2 -2
- data/app/models/action_mcp/session/sse_event.rb +2 -2
- data/app/models/action_mcp/session/subscription.rb +2 -2
- data/app/models/action_mcp/session.rb +68 -43
- data/config/routes.rb +1 -0
- data/db/migrate/20250708105124_create_action_mcp_oauth_clients.rb +42 -0
- data/db/migrate/20250708105226_create_action_mcp_oauth_tokens.rb +37 -0
- data/lib/action_mcp/capability.rb +2 -0
- data/lib/action_mcp/client/json_rpc_handler.rb +9 -9
- data/lib/action_mcp/client/jwt_client_provider.rb +134 -0
- data/lib/action_mcp/configuration.rb +90 -11
- data/lib/action_mcp/engine.rb +26 -1
- data/lib/action_mcp/filtered_logger.rb +32 -0
- data/lib/action_mcp/oauth/active_record_storage.rb +183 -0
- data/lib/action_mcp/oauth/memory_storage.rb +23 -1
- data/lib/action_mcp/oauth/middleware.rb +33 -0
- data/lib/action_mcp/oauth/provider.rb +49 -13
- data/lib/action_mcp/oauth.rb +12 -0
- data/lib/action_mcp/prompt.rb +14 -0
- data/lib/action_mcp/registry_base.rb +25 -4
- data/lib/action_mcp/resource_response.rb +110 -0
- data/lib/action_mcp/resource_template.rb +30 -2
- data/lib/action_mcp/server/capabilities.rb +3 -14
- data/lib/action_mcp/server/memory_session.rb +0 -1
- data/lib/action_mcp/server/prompts.rb +8 -1
- data/lib/action_mcp/server/resources.rb +9 -6
- data/lib/action_mcp/server/tools.rb +41 -20
- data/lib/action_mcp/server.rb +6 -3
- data/lib/action_mcp/sse_listener.rb +0 -7
- data/lib/action_mcp/test_helper.rb +5 -0
- data/lib/action_mcp/tool.rb +108 -4
- data/lib/action_mcp/tools_registry.rb +3 -0
- data/lib/action_mcp/version.rb +1 -1
- data/lib/generators/action_mcp/install/templates/mcp.yml +16 -16
- data/lib/tasks/action_mcp_tasks.rake +238 -0
- metadata +11 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 83f504b6f7171acf10239a3873a3cf967243ed8fa598603b72ec3e265cf9ba7a
|
4
|
+
data.tar.gz: 4b50494bcf216f93243d2068c2d9f211c25a9fe5166fb01f21518321716a4d54
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
57
|
-
bin/rails
|
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
|
-
|
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: "
|
134
|
-
property :b, type: "number", description: "
|
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
|
-
|
205
|
+
# Starting to resolve product: #{template.product_id}
|
201
206
|
end
|
202
207
|
|
203
208
|
after_resolve do |template|
|
204
|
-
|
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
|
-
|
214
|
+
# Starting resolution for product: #{template.product_id}
|
210
215
|
|
211
216
|
resource = block.call
|
212
217
|
|
213
218
|
if resource
|
214
|
-
|
219
|
+
# Product #{template.product_id} resolved successfully in #{Time.current - start_time}s
|
215
220
|
else
|
216
|
-
|
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. **
|
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 (
|
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:
|
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:
|
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:
|
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 =
|
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
|
-
|
161
|
-
# return unless validate_protocol_version_header
|
165
|
+
return unless validate_protocol_version_header
|
162
166
|
|
163
|
-
unless
|
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-
|
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
|
229
|
-
return true if
|
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)
|
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
|
-
|
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
|
-
|
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
|
-
|
251
|
-
|
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
|
-
|
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
|
-
|
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[
|
28
|
+
if oauth_config[:enable_dynamic_registration]
|
29
29
|
metadata[:registration_endpoint] = registration_endpoint
|
30
30
|
end
|
31
31
|
|
32
|
-
if oauth_config[
|
33
|
-
metadata[:jwks_uri] = oauth_config[
|
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
|
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[
|
97
|
-
grants << "client_credentials" if oauth_config[
|
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[
|
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
|
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[
|
113
|
+
if oauth_config[:pkce_required] || oauth_config[:pkce_supported]
|
114
114
|
methods << "S256"
|
115
|
-
methods << "plain" if oauth_config[
|
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
|
121
|
+
oauth_config.fetch(:service_documentation, "#{request.base_url}/docs")
|
122
122
|
end
|
123
123
|
|
124
124
|
def resource_documentation
|
125
|
-
oauth_config
|
125
|
+
oauth_config.fetch(:resource_documentation, "#{request.base_url}/docs/api")
|
126
126
|
end
|
127
127
|
end
|
128
128
|
end
|