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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +50 -7
  3. data/app/controllers/action_mcp/application_controller.rb +123 -34
  4. data/app/models/action_mcp/session.rb +2 -2
  5. data/db/migrate/20250608112101_add_oauth_to_sessions.rb +8 -8
  6. data/lib/action_mcp/client/base.rb +2 -1
  7. data/lib/action_mcp/client/elicitation.rb +34 -0
  8. data/lib/action_mcp/client/json_rpc_handler.rb +14 -2
  9. data/lib/action_mcp/configuration.rb +10 -1
  10. data/lib/action_mcp/content/resource_link.rb +42 -0
  11. data/lib/action_mcp/json_rpc_handler_base.rb +3 -0
  12. data/lib/action_mcp/prompt.rb +17 -1
  13. data/lib/action_mcp/renderable.rb +18 -0
  14. data/lib/action_mcp/resource_template.rb +18 -2
  15. data/lib/action_mcp/server/active_record_session_store.rb +28 -0
  16. data/lib/action_mcp/server/capabilities.rb +4 -3
  17. data/lib/action_mcp/server/elicitation.rb +64 -0
  18. data/lib/action_mcp/server/json_rpc_handler.rb +14 -2
  19. data/lib/action_mcp/server/memory_session.rb +16 -3
  20. data/lib/action_mcp/server/messaging.rb +10 -6
  21. data/lib/action_mcp/server/solid_mcp_adapter.rb +171 -0
  22. data/lib/action_mcp/server/test_session_store.rb +28 -0
  23. data/lib/action_mcp/server/tools.rb +1 -0
  24. data/lib/action_mcp/server/transport_handler.rb +1 -0
  25. data/lib/action_mcp/server/volatile_session_store.rb +24 -0
  26. data/lib/action_mcp/server.rb +4 -4
  27. data/lib/action_mcp/tagged_stream_logging.rb +26 -5
  28. data/lib/action_mcp/tool.rb +101 -7
  29. data/lib/action_mcp/tool_response.rb +16 -5
  30. data/lib/action_mcp/types/float_array_type.rb +58 -0
  31. data/lib/action_mcp/version.rb +1 -1
  32. data/lib/action_mcp.rb +9 -3
  33. data/lib/generators/action_mcp/install/install_generator.rb +1 -1
  34. data/lib/generators/action_mcp/install/templates/mcp.yml +3 -2
  35. metadata +22 -4
  36. 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: 3347c3ede8a67c1b428c9f6a5e0f6d2f9f465b2e5105ae7f87e523b0af5b01ee
4
- data.tar.gz: 3ccb75d093be9e4f8aad07a4a5fd09e4b37e67dabb50ad61a38d53bfdfd33882
3
+ metadata.gz: 2384a0cea84e17ca34e3c46e4029e2dc008561ebeeb8e52f15cb05c3fb5f5eb4
4
+ data.tar.gz: ccba3d9432ae0be54a4950e2f5d357c55fdd39cec2766253e2b3093dba9eedd8
5
5
  SHA512:
6
- metadata.gz: cec3c090e5ce988fc94b10ad58f361aad563deac598907b9a711a5e4ab52a5ac7e661e7969de2ec65f5849278032bd2671a2d5457400716840fb6cebba7e2388
7
- data.tar.gz: f1a8f3961ceefab410615e013a65e6b5cb11cc2427599ff2a8b697c1cd04d9d1cb8398e35b9ac63b98c579530cfe7c48f108703ecca65d4fb915c6c20f505367
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
- This will load the ActionMCP library so you can start defining MCP prompts, tools, and resources in your application.
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
- #### SolidCable (Database-backed, Recommended)
309
+ #### SolidMCP (Database-backed, Recommended)
295
310
 
296
- For SolidCable, add it to your Gemfile:
311
+ For SolidMCP, add it to your Gemfile:
297
312
 
298
313
  ```ruby
299
- gem "solid_cable" # Database-backed adapter (no Redis needed)
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 solid_cable:install
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. You'll need to configure it in your `config/mcp.yml`. You can create this file with `bin/rails g action_mcp:config`.
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::Metal
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
- future = Concurrent::Promises.future { write_sse_event(sse, session, { type: "ping" }) }
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
- return render_not_acceptable(post_accept_headers_error_message) unless post_accept_headers_valid?
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
- return render_bad_request("Mcp-Session-Id header is required for this request.")
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
- return render_not_found("Session not found.")
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
- return render_not_found("Session has been terminated.")
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
- render_internal_server_error("An unexpected error occurred.") unless performed?
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
- if session
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: self.class::REQUIRED_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
- data = payload.is_a?(String) ? payload : MultiJson.dump(payload)
320
- sse_event = "id: #{event_id}\ndata: #{data}\n\n"
321
- sse.write(sse_event)
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
- render json: { jsonrpc: "2.0", error: { code: -32_600, message: message } }
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
- render json: { jsonrpc: "2.0", error: { code: -32_001, message: message } }
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
- render json: { jsonrpc: "2.0", error: { code: -32_601, message: message } }
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
- render json: { jsonrpc: "2.0", error: { code: -32_002, message: message } }
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
- render json: { jsonrpc: "2.0", error: { code: -32_003, message: message } }
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: PROTOCOL_VERSION,
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: PROTOCOL_VERSION,
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
- puts "\e[31mUnknown server method: #{rpc_method} #{id} #{params}\e[0m"
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"] || PROTOCOL_VERSION,
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
@@ -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
  # ---------------------------------------------------