actionmcp 0.55.2 → 0.60.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +50 -7
- data/app/controllers/action_mcp/application_controller.rb +123 -34
- data/app/models/action_mcp/session.rb +2 -2
- data/db/migrate/20250608112101_add_oauth_to_sessions.rb +8 -8
- data/lib/action_mcp/client/base.rb +2 -1
- data/lib/action_mcp/client/elicitation.rb +34 -0
- data/lib/action_mcp/client/json_rpc_handler.rb +14 -2
- data/lib/action_mcp/configuration.rb +10 -1
- data/lib/action_mcp/content/resource_link.rb +42 -0
- data/lib/action_mcp/json_rpc_handler_base.rb +3 -0
- data/lib/action_mcp/prompt.rb +17 -1
- data/lib/action_mcp/renderable.rb +18 -0
- data/lib/action_mcp/resource_template.rb +18 -2
- data/lib/action_mcp/server/active_record_session_store.rb +28 -0
- data/lib/action_mcp/server/capabilities.rb +4 -3
- data/lib/action_mcp/server/elicitation.rb +64 -0
- data/lib/action_mcp/server/json_rpc_handler.rb +14 -2
- data/lib/action_mcp/server/memory_session.rb +16 -3
- data/lib/action_mcp/server/messaging.rb +10 -6
- data/lib/action_mcp/server/solid_mcp_adapter.rb +171 -0
- data/lib/action_mcp/server/test_session_store.rb +28 -0
- data/lib/action_mcp/server/tools.rb +1 -0
- data/lib/action_mcp/server/transport_handler.rb +1 -0
- data/lib/action_mcp/server/volatile_session_store.rb +24 -0
- data/lib/action_mcp/server.rb +4 -4
- data/lib/action_mcp/tagged_stream_logging.rb +26 -5
- data/lib/action_mcp/tool.rb +101 -7
- data/lib/action_mcp/tool_response.rb +16 -5
- data/lib/action_mcp/types/float_array_type.rb +58 -0
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +9 -3
- data/lib/generators/action_mcp/install/install_generator.rb +1 -1
- data/lib/generators/action_mcp/install/templates/mcp.yml +3 -2
- metadata +22 -4
- data/lib/action_mcp/server/solid_cable_adapter.rb +0 -221
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2384a0cea84e17ca34e3c46e4029e2dc008561ebeeb8e52f15cb05c3fb5f5eb4
|
4
|
+
data.tar.gz: ccba3d9432ae0be54a4950e2f5d357c55fdd39cec2766253e2b3093dba9eedd8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e7ed0c8b71d4211dc48388a7544c17ecb9ff674faad9274cd1225cb3e3a65f76bcb546c2c2353882a1ade9016670659fed38aa1c90aa94e17479149012e1be01
|
7
|
+
data.tar.gz: add51e3d310ec89d96bef2767008bc4be9f9bddfeda60ca826d6e9168485771ebf61c03e1011c889d0a57fb4ff3d7bb0e0f5c5046cb35ca69a5285d647fe71d2
|
data/README.md
CHANGED
@@ -22,6 +22,12 @@ This means an AI (like an LLM) can request information or actions from your appl
|
|
22
22
|
|
23
23
|
**ActionMCP** is targeted at developers building MCP-enabled Rails applications. It simplifies the process of integrating Ruby and Rails apps with the MCP standard by providing a set of base classes and an easy-to-use server interface.
|
24
24
|
|
25
|
+
## Protocol Support
|
26
|
+
|
27
|
+
ActionMCP supports **MCP 2025-06-18** (current) with backward compatibility for **MCP 2025-03-26**. For a detailed (and entertaining) breakdown of protocol versions, features, and our design decisions, see [The Hitchhiker's Guide to MCP](The_Hitchhikers_Guide_to_MCP.md).
|
28
|
+
|
29
|
+
*Don't Panic: The guide contains everything you need to know about surviving MCP protocol versions.*
|
30
|
+
|
25
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.
|
26
32
|
|
27
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.
|
@@ -42,7 +48,16 @@ To start using ActionMCP, add it to your project:
|
|
42
48
|
$ bundle add actionmcp
|
43
49
|
```
|
44
50
|
|
45
|
-
|
51
|
+
After adding the gem, run the install generator to set up the basic ActionMCP structure:
|
52
|
+
|
53
|
+
```bash
|
54
|
+
bundle install
|
55
|
+
bin/rails action_mcp:install:migrations
|
56
|
+
bin/rails db:migrate
|
57
|
+
bin/rails generate action_mcp:install
|
58
|
+
```
|
59
|
+
|
60
|
+
This will create the base application classes, configuration file, and necessary database tables for ActionMCP to function properly.
|
46
61
|
|
47
62
|
## Core Components
|
48
63
|
|
@@ -291,22 +306,23 @@ production:
|
|
291
306
|
max_queue: 500 # Maximum number of tasks that can be queued
|
292
307
|
```
|
293
308
|
|
294
|
-
####
|
309
|
+
#### SolidMCP (Database-backed, Recommended)
|
295
310
|
|
296
|
-
For
|
311
|
+
For SolidMCP, add it to your Gemfile:
|
297
312
|
|
298
313
|
```ruby
|
299
|
-
gem "
|
314
|
+
gem "solid_mcp" # Database-backed adapter optimized for MCP
|
300
315
|
```
|
301
316
|
|
302
317
|
Then install it:
|
303
318
|
|
304
319
|
```bash
|
305
320
|
bundle install
|
306
|
-
bin/rails
|
321
|
+
bin/rails solid_mcp:install:migrations
|
322
|
+
bin/rails db:migrate
|
307
323
|
```
|
308
324
|
|
309
|
-
The installer will create the necessary database migration
|
325
|
+
The installer will create the necessary database migration for message storage. Configure it in your `config/mcp.yml`.
|
310
326
|
|
311
327
|
#### Redis Adapter
|
312
328
|
|
@@ -617,6 +633,33 @@ run ActionMCP::Engine
|
|
617
633
|
bin/rails s -c mcp.ru -p 62770 -P tmp/pids/mcps0.pid
|
618
634
|
```
|
619
635
|
|
636
|
+
### Dealing with Middleware Conflicts
|
637
|
+
|
638
|
+
If your Rails application uses middleware that interferes with MCP server operation (like Devise, Warden, Ahoy, Rack::Cors, etc.), use `mcp_vanilla.ru` instead:
|
639
|
+
|
640
|
+
```ruby
|
641
|
+
# mcp_vanilla.ru - A minimal Rack app with only essential middleware
|
642
|
+
# This avoids conflicts with authentication, tracking, and other web-specific middleware
|
643
|
+
# See the file for detailed documentation on when and why to use it
|
644
|
+
|
645
|
+
bundle exec rails s -c mcp_vanilla.ru -p 62770
|
646
|
+
# Or with Falcon:
|
647
|
+
bundle exec falcon serve --bind http://0.0.0.0:62770 --config mcp_vanilla.ru
|
648
|
+
```
|
649
|
+
|
650
|
+
Common middleware that can cause issues:
|
651
|
+
- **Devise/Warden** - Expects cookies and sessions, throws `Devise::MissingWarden` errors
|
652
|
+
- **Ahoy** - Analytics tracking that intercepts requests
|
653
|
+
- **Rack::Attack** - Rate limiting designed for web traffic
|
654
|
+
- **Rack::Cors** - CORS headers meant for browsers
|
655
|
+
- Any middleware assuming HTML responses or cookie-based authentication
|
656
|
+
|
657
|
+
An example of a minimal `mcp_vanilla.ru` file is located in the dummy app : test/dummy/mcp_vanilla.ru.
|
658
|
+
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
|
+
But remember to add any instrumentation or logging middleware you need, as the minimal setup will not include them by default.
|
660
|
+
|
661
|
+
```ruby
|
662
|
+
|
620
663
|
## Production Deployment of MCPS0
|
621
664
|
|
622
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).
|
@@ -631,7 +674,7 @@ Run MCPS0 on its own TCP port (commonly `62770`):
|
|
631
674
|
|
632
675
|
**With Falcon:**
|
633
676
|
```bash
|
634
|
-
bundle exec falcon serve --bind http://0.0.0.0:62770 mcp.ru
|
677
|
+
bundle exec falcon serve --bind http://0.0.0.0:62770 --config mcp.ru
|
635
678
|
```
|
636
679
|
|
637
680
|
**With Puma:**
|
@@ -4,16 +4,13 @@ module ActionMCP
|
|
4
4
|
# Implements the MCP endpoints according to the 2025-03-26 specification.
|
5
5
|
# Supports GET for server-initiated SSE streams, POST for client messages
|
6
6
|
# (responding with JSON or SSE), and optionally DELETE for session termination.
|
7
|
-
class ApplicationController < ActionController::
|
8
|
-
REQUIRED_PROTOCOL_VERSION = "2025-03-26"
|
7
|
+
class ApplicationController < ActionController::API
|
9
8
|
MCP_SESSION_ID_HEADER = "Mcp-Session-Id"
|
10
9
|
|
11
|
-
ActionController::API.without_modules(:StrongParameters, :ParamsWrapper).each do |left|
|
12
|
-
include left
|
13
|
-
end
|
14
10
|
include Engine.routes.url_helpers
|
15
11
|
include JSONRPC_Rails::ControllerHelpers
|
16
12
|
include ActionController::Live
|
13
|
+
include ActionController::Instrumentation
|
17
14
|
|
18
15
|
# Provides the ActionMCP::Session for the current request.
|
19
16
|
# Handles finding existing sessions via header/param or initializing a new one.
|
@@ -56,6 +53,8 @@ module ActionMCP
|
|
56
53
|
response.headers["X-Accel-Buffering"] = "no"
|
57
54
|
response.headers["Cache-Control"] = "no-cache"
|
58
55
|
response.headers["Connection"] = "keep-alive"
|
56
|
+
# Add MCP-Protocol-Version header for established sessions
|
57
|
+
response.headers["MCP-Protocol-Version"] = session.protocol_version
|
59
58
|
|
60
59
|
Rails.logger.info "Unified SSE (GET): Starting stream for session: #{session.id}"
|
61
60
|
|
@@ -97,7 +96,13 @@ module ActionMCP
|
|
97
96
|
heartbeat_sender = lambda do
|
98
97
|
if connection_active.true? && !response.stream.closed?
|
99
98
|
begin
|
100
|
-
|
99
|
+
# Send a proper JSON-RPC notification for heartbeat
|
100
|
+
ping_notification = {
|
101
|
+
jsonrpc: "2.0",
|
102
|
+
method: "notifications/ping",
|
103
|
+
params: {}
|
104
|
+
}
|
105
|
+
future = Concurrent::Promises.future { write_sse_event(sse, session, ping_notification) }
|
101
106
|
future.value!(5)
|
102
107
|
if heartbeat_active.true?
|
103
108
|
heartbeat_task = Concurrent::ScheduledTask.execute(heartbeat_interval, &heartbeat_sender)
|
@@ -137,19 +142,34 @@ module ActionMCP
|
|
137
142
|
# Handles POST requests containing client JSON-RPC messages according to 2025-03-26 spec.
|
138
143
|
# @route POST /mcp
|
139
144
|
def create
|
140
|
-
|
145
|
+
unless post_accept_headers_valid?
|
146
|
+
id = extract_jsonrpc_id_from_params
|
147
|
+
return render_not_acceptable(post_accept_headers_error_message, id)
|
148
|
+
end
|
149
|
+
|
150
|
+
# Reject JSON-RPC batch requests as per MCP 2025-06-18 spec
|
151
|
+
if jsonrpc_params_batch?
|
152
|
+
return render_bad_request("JSON-RPC batch requests are not supported", nil)
|
153
|
+
end
|
141
154
|
|
142
155
|
is_initialize_request = check_if_initialize_request(jsonrpc_params)
|
143
156
|
session_initially_missing = extract_session_id.nil?
|
144
157
|
session = mcp_session
|
145
158
|
|
159
|
+
# Validate MCP-Protocol-Version header for non-initialize requests
|
160
|
+
# Temporarily disabled to debug session issues
|
161
|
+
# return unless validate_protocol_version_header
|
162
|
+
|
146
163
|
unless is_initialize_request
|
147
164
|
if session_initially_missing
|
148
|
-
|
165
|
+
id = jsonrpc_params.respond_to?(:id) ? jsonrpc_params.id : nil
|
166
|
+
return render_bad_request("Mcp-Session-Id header is required for this request.", id)
|
149
167
|
elsif session.nil? || session.new_record?
|
150
|
-
|
168
|
+
id = jsonrpc_params.respond_to?(:id) ? jsonrpc_params.id : nil
|
169
|
+
return render_not_found("Session not found.", id)
|
151
170
|
elsif session.status == "closed"
|
152
|
-
|
171
|
+
id = jsonrpc_params.respond_to?(:id) ? jsonrpc_params.id : nil
|
172
|
+
return render_not_found("Session has been terminated.", id)
|
153
173
|
end
|
154
174
|
end
|
155
175
|
|
@@ -173,7 +193,8 @@ module ActionMCP
|
|
173
193
|
end
|
174
194
|
rescue StandardError => e
|
175
195
|
Rails.logger.error "Unified POST Error: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}"
|
176
|
-
|
196
|
+
id = jsonrpc_params.respond_to?(:id) ? jsonrpc_params.id : nil rescue nil
|
197
|
+
render_internal_server_error("An unexpected error occurred.", id) unless performed?
|
177
198
|
end
|
178
199
|
|
179
200
|
# Handles DELETE requests for session termination (2025-03-26 spec).
|
@@ -201,24 +222,50 @@ module ActionMCP
|
|
201
222
|
|
202
223
|
private
|
203
224
|
|
225
|
+
# Validates the MCP-Protocol-Version header for non-initialize requests
|
226
|
+
# Returns true if valid, renders error and returns false if invalid
|
227
|
+
def validate_protocol_version_header
|
228
|
+
# Skip validation for initialize requests
|
229
|
+
return true if check_if_initialize_request(jsonrpc_params)
|
230
|
+
|
231
|
+
header_version = request.headers["MCP-Protocol-Version"]
|
232
|
+
session = mcp_session
|
233
|
+
|
234
|
+
# If header is missing, assume 2025-03-26 for backward compatibility
|
235
|
+
if header_version.nil?
|
236
|
+
Rails.logger.debug "MCP-Protocol-Version header missing, assuming 2025-03-26 for backward compatibility"
|
237
|
+
return true
|
238
|
+
end
|
239
|
+
|
240
|
+
# Check if the header version is supported
|
241
|
+
unless ActionMCP::SUPPORTED_VERSIONS.include?(header_version)
|
242
|
+
render_bad_request("Unsupported MCP-Protocol-Version: #{header_version}")
|
243
|
+
return false
|
244
|
+
end
|
245
|
+
|
246
|
+
# If we have an initialized session, check if the header matches the negotiated version
|
247
|
+
if session && session.initialized?
|
248
|
+
negotiated_version = session.protocol_version
|
249
|
+
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})")
|
252
|
+
return false
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
true
|
257
|
+
end
|
258
|
+
|
204
259
|
# Finds an existing session based on header or param, or initializes a new one.
|
205
260
|
# Note: This doesn't save the new session; that happens upon first use or explicitly.
|
206
261
|
def find_or_initialize_session
|
207
262
|
session_id = extract_session_id
|
208
263
|
if session_id
|
209
264
|
session = Server.session_store.load_session(session_id)
|
210
|
-
|
211
|
-
if ActionMCP.configuration.vibed_ignore_version
|
212
|
-
if session.protocol_version != self.class::REQUIRED_PROTOCOL_VERSION
|
213
|
-
session.update!(protocol_version: self.class::REQUIRED_PROTOCOL_VERSION)
|
214
|
-
end
|
215
|
-
elsif session.protocol_version != self.class::REQUIRED_PROTOCOL_VERSION
|
216
|
-
session.update!(protocol_version: self.class::REQUIRED_PROTOCOL_VERSION)
|
217
|
-
end
|
218
|
-
end
|
265
|
+
# Session protocol version is set during initialization and should not be overridden
|
219
266
|
session
|
220
267
|
else
|
221
|
-
Server.session_store.create_session(nil, protocol_version:
|
268
|
+
Server.session_store.create_session(nil, protocol_version: ActionMCP::DEFAULT_PROTOCOL_VERSION)
|
222
269
|
end
|
223
270
|
end
|
224
271
|
|
@@ -289,6 +336,10 @@ module ActionMCP
|
|
289
336
|
# Renders the JSON-RPC response(s) as a direct JSON HTTP response.
|
290
337
|
def render_json_response(payload, session, add_session_header)
|
291
338
|
response.headers[MCP_SESSION_ID_HEADER] = session.id if add_session_header
|
339
|
+
# Add MCP-Protocol-Version header if session has been initialized
|
340
|
+
if session && session.initialized?
|
341
|
+
response.headers["MCP-Protocol-Version"] = session.protocol_version
|
342
|
+
end
|
292
343
|
response.headers["Content-Type"] = "application/json"
|
293
344
|
render json: payload, status: :ok
|
294
345
|
end
|
@@ -296,6 +347,10 @@ module ActionMCP
|
|
296
347
|
# Renders the JSON-RPC response(s) as an SSE stream.
|
297
348
|
def render_sse_response(payload, session, add_session_header)
|
298
349
|
response.headers[MCP_SESSION_ID_HEADER] = session.id if add_session_header
|
350
|
+
# Add MCP-Protocol-Version header if session has been initialized
|
351
|
+
if session && session.initialized?
|
352
|
+
response.headers["MCP-Protocol-Version"] = session.protocol_version
|
353
|
+
end
|
299
354
|
response.headers["Content-Type"] = "text/event-stream"
|
300
355
|
response.headers["X-Accel-Buffering"] = "no"
|
301
356
|
response.headers["Cache-Control"] = "no-cache"
|
@@ -316,9 +371,18 @@ module ActionMCP
|
|
316
371
|
# Also stores the event for potential resumability.
|
317
372
|
def write_sse_event(sse, session, payload)
|
318
373
|
event_id = session.increment_sse_counter!
|
319
|
-
|
320
|
-
|
321
|
-
|
374
|
+
# Ensure we're always writing valid JSON strings
|
375
|
+
data = case payload
|
376
|
+
when String
|
377
|
+
payload
|
378
|
+
when Hash
|
379
|
+
MultiJson.dump(payload)
|
380
|
+
else
|
381
|
+
MultiJson.dump(payload.to_h)
|
382
|
+
end
|
383
|
+
# Use the SSE class's write method with proper options
|
384
|
+
# According to MCP spec, we need to send with event type "message"
|
385
|
+
sse.write(data, event: "message", id: event_id)
|
322
386
|
|
323
387
|
begin
|
324
388
|
session.store_sse_event(event_id, payload, session.max_stored_sse_events)
|
@@ -346,33 +410,58 @@ module ActionMCP
|
|
346
410
|
# --- Error Rendering Methods ---
|
347
411
|
|
348
412
|
# Renders a 400 Bad Request response with a JSON-RPC-like error structure.
|
349
|
-
def render_bad_request(message = "Bad Request")
|
350
|
-
|
413
|
+
def render_bad_request(message = "Bad Request", id = nil)
|
414
|
+
id ||= extract_jsonrpc_id_from_request
|
415
|
+
render json: { jsonrpc: "2.0", id: id, error: { code: -32_600, message: message } }
|
351
416
|
end
|
352
417
|
|
353
418
|
# Renders a 404 Not Found response with a JSON-RPC-like error structure.
|
354
|
-
def render_not_found(message = "Not Found")
|
355
|
-
|
419
|
+
def render_not_found(message = "Not Found", id = nil)
|
420
|
+
id ||= extract_jsonrpc_id_from_request
|
421
|
+
render json: { jsonrpc: "2.0", id: id, error: { code: -32_001, message: message } }
|
356
422
|
end
|
357
423
|
|
358
424
|
# Renders a 405 Method Not Allowed response.
|
359
|
-
def render_method_not_allowed(message = "Method Not Allowed")
|
360
|
-
|
425
|
+
def render_method_not_allowed(message = "Method Not Allowed", id = nil)
|
426
|
+
id ||= extract_jsonrpc_id_from_request
|
427
|
+
render json: { jsonrpc: "2.0", id: id, error: { code: -32_601, message: message } }
|
361
428
|
end
|
362
429
|
|
363
430
|
# Renders a 406 Not Acceptable response.
|
364
|
-
def render_not_acceptable(message = "Not Acceptable")
|
365
|
-
|
431
|
+
def render_not_acceptable(message = "Not Acceptable", id = nil)
|
432
|
+
id ||= extract_jsonrpc_id_from_request
|
433
|
+
render json: { jsonrpc: "2.0", id: id, error: { code: -32_002, message: message } }
|
366
434
|
end
|
367
435
|
|
368
436
|
# Renders a 501 Not Implemented response.
|
369
|
-
def render_not_implemented(message = "Not Implemented")
|
370
|
-
|
437
|
+
def render_not_implemented(message = "Not Implemented", id = nil)
|
438
|
+
id ||= extract_jsonrpc_id_from_request
|
439
|
+
render json: { jsonrpc: "2.0", id: id, error: { code: -32_003, message: message } }
|
371
440
|
end
|
372
441
|
|
373
442
|
# Renders a 500 Internal Server Error response.
|
374
443
|
def render_internal_server_error(message = "Internal Server Error", id = nil)
|
375
444
|
render json: { jsonrpc: "2.0", id: id, error: { code: -32_000, message: message } }
|
376
445
|
end
|
446
|
+
|
447
|
+
# Extract JSON-RPC ID from request
|
448
|
+
def extract_jsonrpc_id_from_request
|
449
|
+
# Try to get from already parsed jsonrpc_params first
|
450
|
+
if defined?(jsonrpc_params) && jsonrpc_params
|
451
|
+
return jsonrpc_params.respond_to?(:id) ? jsonrpc_params.id : nil
|
452
|
+
end
|
453
|
+
|
454
|
+
# Otherwise try to parse from raw body, this need refactoring
|
455
|
+
return nil unless request.post? && request.content_type&.include?("application/json")
|
456
|
+
|
457
|
+
begin
|
458
|
+
body = request.body.read
|
459
|
+
request.body.rewind # Reset for subsequent reads
|
460
|
+
json = JSON.parse(body)
|
461
|
+
json["id"]
|
462
|
+
rescue JSON::ParserError, StandardError
|
463
|
+
nil
|
464
|
+
end
|
465
|
+
end
|
377
466
|
end
|
378
467
|
end
|
@@ -75,7 +75,7 @@ module ActionMCP
|
|
75
75
|
before_create :set_server_info, if: -> { role == "server" }
|
76
76
|
before_create :set_server_capabilities, if: -> { role == "server" }
|
77
77
|
|
78
|
-
validates :protocol_version, inclusion: { in: SUPPORTED_VERSIONS }, allow_nil: true, unless: lambda {
|
78
|
+
validates :protocol_version, inclusion: { in: ActionMCP::SUPPORTED_VERSIONS }, allow_nil: true, unless: lambda {
|
79
79
|
ActionMCP.configuration.vibed_ignore_version
|
80
80
|
}
|
81
81
|
|
@@ -130,7 +130,7 @@ module ActionMCP
|
|
130
130
|
|
131
131
|
def server_capabilities_payload
|
132
132
|
{
|
133
|
-
protocolVersion:
|
133
|
+
protocolVersion: protocol_version || ActionMCP::DEFAULT_PROTOCOL_VERSION,
|
134
134
|
serverInfo: server_info,
|
135
135
|
capabilities: server_capabilities
|
136
136
|
}
|
@@ -5,15 +5,15 @@ class AddOAuthToSessions < ActiveRecord::Migration[8.0]
|
|
5
5
|
# Use json for all databases (PostgreSQL, SQLite3, MySQL) for consistency
|
6
6
|
json_type = :json
|
7
7
|
|
8
|
-
add_column :action_mcp_sessions, :oauth_access_token, :string
|
9
|
-
add_column :action_mcp_sessions, :oauth_refresh_token, :string
|
10
|
-
add_column :action_mcp_sessions, :oauth_token_expires_at, :datetime
|
11
|
-
add_column :action_mcp_sessions, :oauth_user_context, json_type
|
12
|
-
add_column :action_mcp_sessions, :authentication_method, :string, default: "none"
|
8
|
+
add_column :action_mcp_sessions, :oauth_access_token, :string unless column_exists?(:action_mcp_sessions, :oauth_access_token)
|
9
|
+
add_column :action_mcp_sessions, :oauth_refresh_token, :string unless column_exists?(:action_mcp_sessions, :oauth_refresh_token)
|
10
|
+
add_column :action_mcp_sessions, :oauth_token_expires_at, :datetime unless column_exists?(:action_mcp_sessions, :oauth_token_expires_at)
|
11
|
+
add_column :action_mcp_sessions, :oauth_user_context, json_type unless column_exists?(:action_mcp_sessions, :oauth_user_context)
|
12
|
+
add_column :action_mcp_sessions, :authentication_method, :string, default: "none" unless column_exists?(:action_mcp_sessions, :authentication_method)
|
13
13
|
|
14
14
|
# Add indexes for performance
|
15
|
-
add_index :action_mcp_sessions, :oauth_access_token, unique: true
|
16
|
-
add_index :action_mcp_sessions, :oauth_token_expires_at
|
17
|
-
add_index :action_mcp_sessions, :authentication_method
|
15
|
+
add_index :action_mcp_sessions, :oauth_access_token, unique: true unless index_exists?(:action_mcp_sessions, :oauth_access_token)
|
16
|
+
add_index :action_mcp_sessions, :oauth_token_expires_at unless index_exists?(:action_mcp_sessions, :oauth_token_expires_at)
|
17
|
+
add_index :action_mcp_sessions, :authentication_method unless index_exists?(:action_mcp_sessions, :authentication_method)
|
18
18
|
end
|
19
19
|
end
|
@@ -12,6 +12,7 @@ module ActionMCP
|
|
12
12
|
include Prompts
|
13
13
|
include Resources
|
14
14
|
include Roots
|
15
|
+
include Elicitation
|
15
16
|
include Logging
|
16
17
|
|
17
18
|
attr_reader :logger, :transport,
|
@@ -179,7 +180,7 @@ module ActionMCP
|
|
179
180
|
end
|
180
181
|
|
181
182
|
params = {
|
182
|
-
protocolVersion:
|
183
|
+
protocolVersion: ActionMCP::DEFAULT_PROTOCOL_VERSION,
|
183
184
|
capabilities: client_capabilities,
|
184
185
|
clientInfo: client_info
|
185
186
|
}
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
module Client
|
5
|
+
# Handles elicitation requests from servers
|
6
|
+
module Elicitation
|
7
|
+
# Process elicitation request from server
|
8
|
+
# @param id [String, Integer] The request ID
|
9
|
+
# @param params [Hash] The elicitation parameters
|
10
|
+
def process_elicitation_request(id, params)
|
11
|
+
message = params["message"]
|
12
|
+
requested_schema = params["requestedSchema"]
|
13
|
+
|
14
|
+
# In a real implementation, this would prompt the user
|
15
|
+
# For now, we'll just return a decline response
|
16
|
+
# Actual implementations should override this method
|
17
|
+
send_jsonrpc_response(id, result: {
|
18
|
+
action: "decline"
|
19
|
+
})
|
20
|
+
end
|
21
|
+
|
22
|
+
# Send elicitation response
|
23
|
+
# @param id [String, Integer] The request ID
|
24
|
+
# @param action [String] The action taken ("accept", "decline", "cancel")
|
25
|
+
# @param content [Hash, nil] The form data if action is "accept"
|
26
|
+
def send_elicitation_response(id, action:, content: nil)
|
27
|
+
result = { action: action }
|
28
|
+
result[:content] = content if action == "accept" && content
|
29
|
+
|
30
|
+
send_jsonrpc_response(id, result: result)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -50,7 +50,19 @@ module ActionMCP
|
|
50
50
|
# @param id [String, Integer]
|
51
51
|
# @param params [Hash]
|
52
52
|
def handle_method(rpc_method, id, params)
|
53
|
-
|
53
|
+
case rpc_method
|
54
|
+
when Methods::ELICITATION_CREATE
|
55
|
+
client.process_elicitation_request(id, params)
|
56
|
+
when /^roots\//
|
57
|
+
process_roots(rpc_method, id)
|
58
|
+
when /^sampling\//
|
59
|
+
process_sampling(rpc_method, id, params)
|
60
|
+
else
|
61
|
+
common_result = handle_common_methods(rpc_method, id, params)
|
62
|
+
if common_result.nil?
|
63
|
+
puts "\e[31mUnknown server method: #{rpc_method} #{id} #{params}\e[0m"
|
64
|
+
end
|
65
|
+
end
|
54
66
|
end
|
55
67
|
|
56
68
|
# @param rpc_method [String]
|
@@ -168,7 +180,7 @@ module ActionMCP
|
|
168
180
|
# Create a new session with the server-provided ID
|
169
181
|
client.instance_variable_set(:@session, ActionMCP::Session.from_client.new(
|
170
182
|
id: session_id,
|
171
|
-
protocol_version: result["protocolVersion"] ||
|
183
|
+
protocol_version: result["protocolVersion"] || ActionMCP::DEFAULT_PROTOCOL_VERSION,
|
172
184
|
client_info: client.client_info,
|
173
185
|
client_capabilities: client.client_capabilities,
|
174
186
|
server_info: result["serverInfo"],
|
@@ -25,6 +25,7 @@ module ActionMCP
|
|
25
25
|
:logging_level,
|
26
26
|
:active_profile,
|
27
27
|
:profiles,
|
28
|
+
:elicitation_enabled,
|
28
29
|
# --- Authentication Options ---
|
29
30
|
:authentication_methods,
|
30
31
|
:oauth_config,
|
@@ -56,6 +57,7 @@ module ActionMCP
|
|
56
57
|
@list_changed = false
|
57
58
|
@logging_level = :info
|
58
59
|
@resources_subscribe = false
|
60
|
+
@elicitation_enabled = false
|
59
61
|
@active_profile = :primary
|
60
62
|
@profiles = default_profiles
|
61
63
|
|
@@ -65,7 +67,7 @@ module ActionMCP
|
|
65
67
|
|
66
68
|
@sse_heartbeat_interval = 30
|
67
69
|
@post_response_preference = :json
|
68
|
-
@protocol_version = "2025-03-26"
|
70
|
+
@protocol_version = "2025-03-26" # Default to legacy for backwards compatibility
|
69
71
|
@vibed_ignore_version = false
|
70
72
|
|
71
73
|
# Resumability defaults
|
@@ -186,6 +188,8 @@ module ActionMCP
|
|
186
188
|
|
187
189
|
capabilities[:resources] = { subscribe: @resources_subscribe } if filtered_resources.any?
|
188
190
|
|
191
|
+
capabilities[:elicitation] = {} if @elicitation_enabled
|
192
|
+
|
189
193
|
capabilities
|
190
194
|
end
|
191
195
|
|
@@ -269,6 +273,11 @@ module ActionMCP
|
|
269
273
|
@connects_to = app_config["connects_to"]
|
270
274
|
end
|
271
275
|
|
276
|
+
# Extract session store configuration
|
277
|
+
if app_config["session_store_type"]
|
278
|
+
@session_store_type = app_config["session_store_type"].to_sym
|
279
|
+
end
|
280
|
+
|
272
281
|
# Extract client and server session store types
|
273
282
|
if app_config["client_session_store_type"]
|
274
283
|
@client_session_store_type = app_config["client_session_store_type"].to_sym
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
module Content
|
5
|
+
# ResourceLink represents a link to a resource that the server is capable of reading.
|
6
|
+
# It's included in a prompt or tool call result.
|
7
|
+
# Note: resource links returned by tools are not guaranteed to appear in resources/list requests.
|
8
|
+
class ResourceLink < Base
|
9
|
+
# @return [String] The URI of the resource.
|
10
|
+
# @return [String, nil] The name of the resource (optional).
|
11
|
+
# @return [String, nil] The description of the resource (optional).
|
12
|
+
# @return [String, nil] The MIME type of the resource (optional).
|
13
|
+
attr_reader :uri, :name, :description, :mime_type
|
14
|
+
|
15
|
+
# Initializes a new ResourceLink content.
|
16
|
+
#
|
17
|
+
# @param uri [String] The URI of the resource.
|
18
|
+
# @param name [String, nil] The name of the resource (optional).
|
19
|
+
# @param description [String, nil] The description of the resource (optional).
|
20
|
+
# @param mime_type [String, nil] The MIME type of the resource (optional).
|
21
|
+
# @param annotations [Hash, nil] Optional annotations for the resource link.
|
22
|
+
def initialize(uri, name: nil, description: nil, mime_type: nil, annotations: nil)
|
23
|
+
super("resource_link", annotations: annotations)
|
24
|
+
@uri = uri
|
25
|
+
@name = name
|
26
|
+
@description = description
|
27
|
+
@mime_type = mime_type
|
28
|
+
end
|
29
|
+
|
30
|
+
# Returns a hash representation of the resource link content.
|
31
|
+
#
|
32
|
+
# @return [Hash] The hash representation of the resource link content.
|
33
|
+
def to_h
|
34
|
+
result = super.merge(uri: @uri)
|
35
|
+
result[:name] = @name if @name
|
36
|
+
result[:description] = @description if @description
|
37
|
+
result[:mimeType] = @mime_type if @mime_type
|
38
|
+
result
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -28,6 +28,9 @@ module ActionMCP
|
|
28
28
|
# Notification methods
|
29
29
|
NOTIFICATIONS_INITIALIZED = "notifications/initialized"
|
30
30
|
NOTIFICATIONS_CANCELLED = "notifications/cancelled"
|
31
|
+
|
32
|
+
# Elicitation methods
|
33
|
+
ELICITATION_CREATE = "elicitation/create"
|
31
34
|
end
|
32
35
|
|
33
36
|
delegate :initialize!, :initialized?, to: :transport
|
data/lib/action_mcp/prompt.rb
CHANGED
@@ -6,6 +6,7 @@ module ActionMCP
|
|
6
6
|
include ActionMCP::Callbacks
|
7
7
|
include ActionMCP::CurrentHelpers
|
8
8
|
class_attribute :_argument_definitions, instance_accessor: false, default: []
|
9
|
+
class_attribute :_meta, instance_accessor: false, default: {}
|
9
10
|
|
10
11
|
# ---------------------------------------------------
|
11
12
|
# Prompt Name
|
@@ -35,6 +36,16 @@ module ActionMCP
|
|
35
36
|
def type
|
36
37
|
:prompt
|
37
38
|
end
|
39
|
+
|
40
|
+
# Sets or retrieves the _meta field
|
41
|
+
def meta(data = nil)
|
42
|
+
if data
|
43
|
+
raise ArgumentError, "_meta must be a hash" unless data.is_a?(Hash)
|
44
|
+
self._meta = _meta.merge(data)
|
45
|
+
else
|
46
|
+
_meta
|
47
|
+
end
|
48
|
+
end
|
38
49
|
end
|
39
50
|
|
40
51
|
# ---------------------------------------------------
|
@@ -80,11 +91,16 @@ module ActionMCP
|
|
80
91
|
# ---------------------------------------------------
|
81
92
|
# @return [Hash] The prompt definition as a Hash.
|
82
93
|
def self.to_h
|
83
|
-
{
|
94
|
+
result = {
|
84
95
|
name: prompt_name,
|
85
96
|
description: description.presence,
|
86
97
|
arguments: arguments.map { |arg| arg.slice(:name, :description, :required, :type) }
|
87
98
|
}.compact
|
99
|
+
|
100
|
+
# Add _meta if present
|
101
|
+
result[:_meta] = _meta if _meta.any?
|
102
|
+
|
103
|
+
result
|
88
104
|
end
|
89
105
|
|
90
106
|
# ---------------------------------------------------
|