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.
- checksums.yaml +4 -4
- data/README.md +46 -41
- data/app/controllers/action_mcp/application_controller.rb +67 -15
- 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 +22 -22
- 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/client/jwt_client_provider.rb +134 -0
- data/lib/action_mcp/configuration.rb +27 -4
- 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/server/capabilities.rb +0 -3
- data/lib/action_mcp/server/resources.rb +1 -1
- data/lib/action_mcp/server/tools.rb +36 -24
- data/lib/action_mcp/sse_listener.rb +0 -7
- data/lib/action_mcp/test_helper.rb +5 -0
- data/lib/action_mcp/tool.rb +94 -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
- metadata +10 -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
|
@@ -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. **
|
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 (
|
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:
|
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:
|
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:
|
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
|
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-
|
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)
|
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[
|
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
|