actionmcp 0.111.1 → 0.200.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +26 -18
  3. data/Rakefile +3 -0
  4. data/app/controllers/action_mcp/application_controller.rb +166 -109
  5. data/app/controllers/action_mcp/mcp_app_renderer.rb +1 -0
  6. data/app/helpers/action_mcp/apps_helper.rb +48 -0
  7. data/app/jobs/action_mcp/tool_execution_job.rb +67 -35
  8. data/app/models/action_mcp/session/message.rb +8 -3
  9. data/app/models/action_mcp/session/task.rb +15 -5
  10. data/app/models/action_mcp/session.rb +93 -19
  11. data/config/routes.rb +1 -1
  12. data/lib/action_mcp/apps/javascript/ext_apps.LICENSE +216 -0
  13. data/lib/action_mcp/apps/javascript/ext_apps.js +78 -0
  14. data/lib/action_mcp/apps/view_manifest.rb +170 -0
  15. data/lib/action_mcp/apps.rb +17 -2
  16. data/lib/action_mcp/configuration.rb +14 -30
  17. data/lib/action_mcp/content/audio.rb +4 -1
  18. data/lib/action_mcp/content/base.rb +3 -1
  19. data/lib/action_mcp/content/image.rb +4 -1
  20. data/lib/action_mcp/content/resource.rb +15 -14
  21. data/lib/action_mcp/content/resource_link.rb +15 -5
  22. data/lib/action_mcp/content/text.rb +2 -1
  23. data/lib/action_mcp/content/validation.rb +253 -0
  24. data/lib/action_mcp/content.rb +3 -1
  25. data/lib/action_mcp/dev/runner.rb +203 -0
  26. data/lib/action_mcp/engine.rb +24 -2
  27. data/lib/action_mcp/json_rpc_handler_base.rb +7 -12
  28. data/lib/action_mcp/logging/logger.rb +16 -11
  29. data/lib/action_mcp/logging.rb +34 -1
  30. data/lib/action_mcp/middleware/origin_validation.rb +9 -13
  31. data/lib/action_mcp/output_schema_builder.rb +2 -0
  32. data/lib/action_mcp/prompt_response.rb +12 -2
  33. data/lib/action_mcp/protocol_validator.rb +513 -0
  34. data/lib/action_mcp/renderable.rb +4 -4
  35. data/lib/action_mcp/resource.rb +15 -11
  36. data/lib/action_mcp/resource_template.rb +105 -90
  37. data/lib/action_mcp/resource_templates_registry.rb +31 -97
  38. data/lib/action_mcp/schema_validator.rb +43 -0
  39. data/lib/action_mcp/server/base_session.rb +134 -28
  40. data/lib/action_mcp/server/base_session_store.rb +1 -1
  41. data/lib/action_mcp/server/capabilities.rb +10 -30
  42. data/lib/action_mcp/server/client_notifications.rb +63 -0
  43. data/lib/action_mcp/server/completions.rb +68 -0
  44. data/lib/action_mcp/server/elicitation.rb +47 -25
  45. data/lib/action_mcp/server/elicitation_request.rb +136 -51
  46. data/lib/action_mcp/server/handlers/logging_handler.rb +7 -5
  47. data/lib/action_mcp/server/handlers/prompt_handler.rb +12 -1
  48. data/lib/action_mcp/server/handlers/resource_handler.rb +41 -7
  49. data/lib/action_mcp/server/handlers/task_handler.rb +26 -0
  50. data/lib/action_mcp/server/handlers/tool_handler.rb +8 -0
  51. data/lib/action_mcp/server/json_rpc_handler.rb +5 -21
  52. data/lib/action_mcp/server/messaging_service.rb +3 -1
  53. data/lib/action_mcp/server/prompts.rb +5 -3
  54. data/lib/action_mcp/server/resources.rb +35 -2
  55. data/lib/action_mcp/server/response_collector.rb +5 -1
  56. data/lib/action_mcp/server/roots.rb +15 -2
  57. data/lib/action_mcp/server/sampling.rb +36 -1
  58. data/lib/action_mcp/server/sampling_request.rb +317 -55
  59. data/lib/action_mcp/server/tasks.rb +27 -45
  60. data/lib/action_mcp/server/tool_result.rb +27 -0
  61. data/lib/action_mcp/server/tools.rb +12 -10
  62. data/lib/action_mcp/server/transport_handler.rb +3 -0
  63. data/lib/action_mcp/server/url_elicitation_request.rb +13 -0
  64. data/lib/action_mcp/tool.rb +133 -199
  65. data/lib/action_mcp/tool_response.rb +35 -4
  66. data/lib/action_mcp/tools_registry.rb +1 -1
  67. data/lib/action_mcp/version.rb +1 -1
  68. data/lib/action_mcp.rb +2 -5
  69. data/lib/generators/action_mcp/identifier/identifier_generator.rb +1 -1
  70. data/lib/generators/action_mcp/identifier/templates/identifier.rb.erb +1 -1
  71. data/lib/generators/action_mcp/install/templates/bin/mcp +16 -4
  72. data/lib/generators/action_mcp/install/templates/mcp/config.ru.tt +2 -2
  73. data/lib/generators/action_mcp/install/templates/mcp.yml +4 -0
  74. data/lib/generators/action_mcp/resource_template/resource_template_generator.rb +18 -1
  75. data/lib/generators/action_mcp/resource_template/templates/resource_template_ui.rb.erb +27 -0
  76. data/lib/generators/action_mcp/tool/templates/tool.rb.erb +6 -0
  77. data/lib/generators/action_mcp/tool/tool_generator.rb +2 -0
  78. data/lib/generators/action_mcp/view/templates/resource_template.rb.erb +16 -0
  79. data/lib/generators/action_mcp/view/templates/view.html.erb +27 -0
  80. data/lib/generators/action_mcp/view/view_generator.rb +57 -0
  81. data/lib/tasks/action_mcp_apps.rake +17 -0
  82. data/lib/tasks/action_mcp_tasks.rake +135 -74
  83. metadata +47 -17
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 278cf7ec84daea1af24b29413c1a0d4a0c56793f8aebff32c2ca10953bde715b
4
- data.tar.gz: 1bf9dbb492f66f611523a6c802a84565470ff5323459a76d420ab7d5393a826b
3
+ metadata.gz: 5959fcdcfdeed27a18bc9723f51bbfb3699d0983172875e4c8dc9cc6873eadf4
4
+ data.tar.gz: 04ce30167f6377c54dc92e574dc50d027bb9a6f76355d3c96bde33de99ada463
5
5
  SHA512:
6
- metadata.gz: 243bb2bf2d3c844e25b7a83fd74f7aa1c004742124caa1da723d30c25a24ec8211e6eb98f96ea0e53eaa429d66a334a770ffcf6e2186b1096c451ef1e268195a
7
- data.tar.gz: 6b4cfd0bd035f6af788ae8e61215fa6a64e88177369a62a5a507c67c3812494dd06832c26753519c77c826e9985747469443ecc9a073d713a4e7424cc64f6866
6
+ metadata.gz: 7cd9b2994aca071515bd4141186ba95e352f0af355b817b38e45df672ce85d4b404e67df88d98523bb7b8bd5da60ed05aecf163fe5371a24f199c04eb5a3915a
7
+ data.tar.gz: 89de790b55becb12ab8c991a0fffe569c70ebef2ec01a0d29135313708be7cae2a5aa27a6fd9de295c9a5d078e6d4ffe57b91c5395a4133e1c29408a46c35402
data/README.md CHANGED
@@ -24,13 +24,20 @@ This means an AI (like an LLM) can request information or actions from your appl
24
24
 
25
25
  ## Protocol Support
26
26
 
27
- ActionMCP supports **MCP 2025-06-18** (current) with backward compatibility for **MCP 2025-03-26**. The protocol implementation is fully compliant with the MCP specification, including:
27
+ ActionMCP targets the released **MCP 2025-11-25** protocol. Older protocol versions and unreleased draft versions are not accepted. Core handling includes:
28
28
 
29
29
  - **JSON-RPC 2.0** transport layer
30
30
  - **Capability negotiation** during initialization
31
31
  - **Error handling** with proper error codes (-32601 for method not found, -32002 for consent required)
32
- - **Session management** with resumable sessions
33
- - **Change notifications** for dynamic capability updates
32
+ - **Stateful session management** with explicit termination
33
+ - **Tasks, tools, prompts, resources, completion, logging, sampling, roots, and elicitation** message handling
34
+
35
+ The built-in Streamable HTTP endpoint returns one `application/json` message for
36
+ requests and intentionally returns HTTP 405 for GET because it does not provide
37
+ SSE streams. That behavior is allowed by the transport specification. Outbound
38
+ server requests and notifications are retained in session message storage, but
39
+ an application needs an SSE or custom transport to push those messages to a
40
+ client outside the response to an active request.
34
41
 
35
42
  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).
36
43
 
@@ -331,20 +338,12 @@ Call it as a task from a client by adding top-level `task` params (creates a Tas
331
338
  ```
332
339
 
333
340
  Poll task status with `tasks/get` or fetch the result with `tasks/result`.
334
- By default, `tasks/result` uses spec-aligned blocking HTTP: if the task is still working, the request waits until the task reaches `completed`, `failed`, `cancelled`, or `input_required`, then returns one JSON response. Configure the wait bounds for your Rails app:
341
+ As required by MCP 2025-11-25, `tasks/result` waits while a task is in any non-terminal state, including `input_required`, and returns once it reaches `completed`, `failed`, or `cancelled`. The internal database polling interval must be positive and can be tuned for your Rails app:
335
342
 
336
343
  ```ruby
337
- config.action_mcp.tasks_result_strategy = :blocking_http
338
- config.action_mcp.tasks_result_timeout = 30.seconds
339
344
  config.action_mcp.tasks_result_poll_interval = 0.25.seconds
340
345
  ```
341
346
 
342
- Rails apps that cannot hold request workers open can opt into `:polling_only`, where clients must poll `tasks/get` until terminal or `input_required` before calling `tasks/result`. This is a deliberate MCP spec deviation:
343
-
344
- ```ruby
345
- config.action_mcp.tasks_result_strategy = :polling_only
346
- ```
347
-
348
347
  Use `tasks/cancel` to stop non-terminal tasks. `tasks/list` returns tasks in recent-first order and always paginates (default 50 per page, or `pagination_page_size` if configured). The response includes an opaque `nextCursor` when more results are available; treat cursors as opaque tokens.
349
348
 
350
349
  ### ActionMCP::ResourceTemplate
@@ -455,7 +454,7 @@ ActionMCP provides comprehensive documentation across multiple specialized guide
455
454
 
456
455
  ### Protocol & Technical Details
457
456
  - **[🚀 The Hitchhiker's Guide to MCP](The_Hitchhikers_Guide_to_MCP.md)** - Protocol versions and migration
458
- - Comprehensive comparison of MCP protocol versions (2024-11-05, 2025-03-26, 2025-06-18)
457
+ - Historical protocol comparison and the current 2025-11-25 behavior
459
458
  - Design decisions and architectural rationale
460
459
  - Migration paths and compatibility considerations
461
460
  - Feature evolution and technical specifications (*Don't Panic!*)
@@ -628,7 +627,7 @@ session_store = ActionMCP::Server.session_store
628
627
  # Create a session
629
628
  session = session_store.create_session(session_id, {
630
629
  status: "initialized",
631
- protocol_version: "2025-03-26",
630
+ protocol_version: "2025-11-25",
632
631
  # ... other session attributes
633
632
  })
634
633
 
@@ -750,7 +749,7 @@ ActionMCP provides a Gateway system for handling authentication. The Gateway all
750
749
 
751
750
  ActionMCP uses a Gateway pattern with pluggable identifiers for authentication. You can implement custom authentication strategies using session-based auth, API keys, bearer tokens, or integrate with existing authentication systems like Warden, Devise, or external OAuth providers.
752
751
 
753
- > **Note:** Auth errors return HTTP 200 with a JSON-RPC error payload (not HTTP 401). This is correct per the MCP specification all MCP communication uses JSON-RPC over HTTP, and protocol-level errors are expressed within the JSON-RPC envelope. The `initialize` request bypasses authentication per MCP spec.
752
+ > **Note:** When a Gateway is configured, it authenticates every MCP HTTP request, including `initialize`, GET, and DELETE. Authentication failures return HTTP 401 with a `WWW-Authenticate: Bearer` challenge and a JSON-RPC error body.
754
753
 
755
754
  ### Creating an ApplicationGateway
756
755
 
@@ -881,7 +880,7 @@ If your Rails application uses middleware that interferes with MCP server operat
881
880
 
882
881
  bundle exec rails s -c mcp_vanilla.ru -p 62770
883
882
  # Or with Falcon:
884
- bundle exec falcon serve --bind http://0.0.0.0:62770 --config mcp_vanilla.ru
883
+ bundle exec falcon serve --bind http://127.0.0.1:62770 --config mcp_vanilla.ru
885
884
  ```
886
885
 
887
886
  Common middleware that can cause issues:
@@ -1115,6 +1114,7 @@ bundle exec rails action_mcp:list
1115
1114
  bundle exec rails action_mcp:list_tools
1116
1115
  bundle exec rails action_mcp:list_prompts
1117
1116
  bundle exec rails action_mcp:list_resources
1117
+ bundle exec rails action_mcp:list_widgets
1118
1118
  bundle exec rails action_mcp:list_profiles
1119
1119
 
1120
1120
  # Show configuration and statistics
@@ -1125,6 +1125,14 @@ bundle exec rails action_mcp:stats
1125
1125
  bundle exec rails action_mcp:show_profile[profile_name]
1126
1126
  ```
1127
1127
 
1128
+ ### Linting
1129
+
1130
+ RuboCop runs as a git pre-commit hook on staged Ruby files. Enable it once per clone:
1131
+
1132
+ ```bash
1133
+ git config core.hooksPath .githooks
1134
+ ```
1135
+
1128
1136
  ## Error Handling and Troubleshooting
1129
1137
 
1130
1138
  ActionMCP provides comprehensive error handling following the JSON-RPC 2.0 specification:
@@ -1264,7 +1272,7 @@ bin/rails action_mcp:list_profiles
1264
1272
  # Show detailed information about a specific profile
1265
1273
  bin/rails action_mcp:show_profile[admin]
1266
1274
 
1267
- # List all tools, prompts, resources, and profiles
1275
+ # List all tools, prompts, resources, UI widgets, and profiles
1268
1276
  bin/rails action_mcp:list
1269
1277
  ```
1270
1278
 
@@ -1297,7 +1305,7 @@ For comprehensive client documentation, including examples, session management,
1297
1305
  - **Implement proper authorization** in your tools and prompts
1298
1306
  - **Validate all inputs** using property definitions and Rails validations
1299
1307
  - **Use consent management** for sensitive operations
1300
- - **Protect against DNS rebinding**: ActionMCP validates the `Origin` header on every request. If `Origin` is present and its host doesn't match the server's own host, the server returns HTTP 403 with a JSON-RPC error body (no `id`), as required by the MCP spec. Non-browser clients (Claude Desktop, CLI tools) don't send `Origin` and are unaffected. To allow additional trusted origins, configure `allowed_origins`:
1308
+ - **Protect against DNS rebinding**: ActionMCP validates the `Origin` header on every request without trusting the request's `Host` header. Loopback origins (`localhost`, `127.0.0.1`, and `::1`) are allowed by default. Other browser origins receive HTTP 403 unless their host is explicitly listed in `allowed_origins`. Non-browser clients that omit `Origin` are unaffected:
1301
1309
 
1302
1310
  ```ruby
1303
1311
  # config/initializers/action_mcp.rb
data/Rakefile CHANGED
@@ -6,3 +6,6 @@ APP_RAKEFILE = File.expand_path('test/dummy/Rakefile', __dir__)
6
6
  load 'rails/tasks/engine.rake'
7
7
 
8
8
  require 'bundler/gem_tasks'
9
+
10
+ task test: "app:test"
11
+ task default: :test
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionMCP
4
- # Implements the MCP endpoints according to the 2025-03-26 specification.
5
- # Supports GET for server-initiated SSE streams, POST for client messages
6
- # (responding with JSON or SSE), and optionally DELETE for session termination.
4
+ # Implements the MCP endpoints according to the 2025-11-25 specification.
5
+ # POST returns one JSON message, DELETE terminates sessions, and GET returns
6
+ # 405 because the built-in transport does not provide SSE streams.
7
7
  class ApplicationController < ActionController::API
8
8
  MCP_SESSION_ID_HEADER = "Mcp-Session-Id"
9
+ POST_ACCEPT_MEDIA_TYPES = %w[application/json text/event-stream].freeze
9
10
 
10
11
  include Engine.routes.url_helpers
11
12
  include JSONRPC_Rails::ControllerHelpers
@@ -15,11 +16,11 @@ module ActionMCP
15
16
  # (see engine.rb) so invalid requests are rejected before routing.
16
17
 
17
18
  # Provides the ActionMCP::Session for the current request.
18
- # Handles finding existing sessions via header/param or initializing a new one.
19
- # Specific controllers/handlers might need to enforce session ID presence based on context.
20
- # @return [ActionMCP::Session] The session object (might be unsaved if new)
19
+ # @return [ActionMCP::Session, nil] The session identified by the request header.
21
20
  def mcp_session
22
- @mcp_session ||= find_or_initialize_session
21
+ return @mcp_session if defined?(@mcp_session)
22
+
23
+ @mcp_session = load_session(extract_session_id)
23
24
  end
24
25
 
25
26
  # Provides a unique key for caching or pub/sub based on the session ID.
@@ -35,13 +36,25 @@ module ActionMCP
35
36
  # ROUTE: /, name: mcp_get, via: GET
36
37
  # <rails-lens:routes:end>
37
38
  def show
39
+ return unless authenticate_gateway!
40
+
41
+ if (session_id = extract_session_id)
42
+ session = load_session(session_id)
43
+ return render_not_found("Session not found.") unless session
44
+ return render_not_found("Session has been terminated.") if session.status == "closed"
45
+
46
+ @mcp_session = session
47
+ return unless configure_gateway_session!(session)
48
+ return unless validate_protocol_version_header(session)
49
+ end
50
+
38
51
  # MCP Streamable HTTP spec allows servers to return 405 if they don't support SSE.
39
52
  # ActionMCP uses Tasks for async operations instead of SSE streaming.
40
53
  response.headers["Allow"] = "POST, DELETE"
41
54
  head :method_not_allowed
42
55
  end
43
56
 
44
- # Handles POST requests containing client JSON-RPC messages according to 2025-03-26 spec.
57
+ # Handles POST requests containing one client JSON-RPC message.
45
58
  # <rails-lens:routes:begin>
46
59
  # ROUTE: /, name: mcp_post, via: POST
47
60
  # <rails-lens:routes:end>
@@ -51,54 +64,60 @@ module ActionMCP
51
64
  return render_not_acceptable(post_accept_headers_error_message, id)
52
65
  end
53
66
 
54
- # Reject JSON-RPC batch requests as per MCP 2025-06-18 spec
55
- return render_bad_request("JSON-RPC batch requests are not supported", nil) if jsonrpc_params_batch?
56
-
57
- is_initialize_request = check_if_initialize_request(jsonrpc_params)
58
- session_initially_missing = extract_session_id.nil?
59
- session = mcp_session
60
-
61
- # Validate MCP-Protocol-Version header for non-initialize requests
62
- return unless validate_protocol_version_header
63
-
64
- unless initialization_related_request?(jsonrpc_params)
65
- if session_initially_missing
66
- id = jsonrpc_params.respond_to?(:id) ? jsonrpc_params.id : nil
67
- return render_bad_request("Mcp-Session-Id header is required for this request.", id)
68
- elsif session.nil? || session.new_record?
69
- id = jsonrpc_params.respond_to?(:id) ? jsonrpc_params.id : nil
70
- return render_not_found("Session not found.", id)
71
- elsif session.status == "closed"
72
- id = jsonrpc_params.respond_to?(:id) ? jsonrpc_params.id : nil
73
- return render_not_found("Session has been terminated.", id)
67
+ payload = jsonrpc_params
68
+ is_initialize_request = initialize_request?(payload)
69
+ session_id = extract_session_id
70
+
71
+ return unless authenticate_gateway!
72
+
73
+ if (validation_error = ProtocolValidator.client_message_validation_error(payload))
74
+ if payload.is_a?(JSON_RPC::Notification)
75
+ return render_notification_error(validation_error)
74
76
  end
75
- end
76
77
 
77
- if session.nil?
78
- id = jsonrpc_params.respond_to?(:id) ? jsonrpc_params.id : nil
79
- return render_not_found("Session not found.", id)
78
+ return render_jsonrpc_error(validation_error.code, validation_error.message, request_id(payload))
80
79
  end
81
80
 
82
- if session.new_record?
83
- session.save!
84
- response.headers[MCP_SESSION_ID_HEADER] = session.id
85
- end
81
+ session = if is_initialize_request
82
+ if session_id
83
+ existing_session = load_session(session_id)
84
+ return render_not_found("Session not found.", request_id(payload)) unless existing_session
85
+ if existing_session.status == "closed"
86
+ return render_not_found("Session has been terminated.", request_id(payload))
87
+ end
86
88
 
87
- # Authenticate the request via gateway (skipped for initialization-related requests)
88
- if initialization_related_request?(jsonrpc_params)
89
- # Skipping authentication for initialization request: #{jsonrpc_params.method}
89
+ return render_bad_request("Initialize requests must not include an Mcp-Session-Id header.", request_id(payload))
90
+ end
91
+
92
+ create_session
90
93
  else
91
- authenticate_gateway!
92
- return if performed?
94
+ return render_bad_request("Mcp-Session-Id header is required for this request.", request_id(payload)) unless session_id
95
+
96
+ existing_session = load_session(session_id)
97
+ return render_not_found("Session not found.", request_id(payload)) unless existing_session
98
+ if existing_session.status == "closed"
99
+ return render_not_found("Session has been terminated.", request_id(payload))
100
+ end
101
+
102
+ existing_session
93
103
  end
94
104
 
105
+ @mcp_session = session
106
+ unless configure_gateway_session!(session)
107
+ Server.session_store.delete_session(session.id) if is_initialize_request
108
+ return
109
+ end
110
+ return unless is_initialize_request || validate_protocol_version_header(session)
111
+ return unless lifecycle_allows?(payload, session, is_initialize_request)
112
+
95
113
  # Use return mode for the transport handler when we need to capture responses
96
114
  transport_handler = Server::TransportHandler.new(session, messaging_mode: :return)
97
115
  json_rpc_handler = Server::JsonRpcHandler.new(transport_handler)
98
116
 
99
117
  result = json_rpc_handler.call(jsonrpc_params)
100
- process_handler_results(result, session, session_initially_missing, is_initialize_request)
118
+ process_handler_results(result, session, is_initialize_request)
101
119
  rescue StandardError => e
120
+ Server.session_store.delete_session(session.id) if is_initialize_request && session
102
121
  Rails.error.report(e, handled: true, severity: :error)
103
122
  id = begin
104
123
  jsonrpc_params.respond_to?(:id) ? jsonrpc_params.id : nil
@@ -108,24 +127,26 @@ module ActionMCP
108
127
  render_internal_server_error("An unexpected error occurred.", id) unless performed?
109
128
  end
110
129
 
111
- # Handles DELETE requests for session termination (2025-03-26 spec).
130
+ # Handles DELETE requests for session termination.
112
131
  # <rails-lens:routes:begin>
113
132
  # ROUTE: /, name: mcp_delete, via: DELETE
114
133
  # <rails-lens:routes:end>
115
134
  def destroy
135
+ return unless authenticate_gateway!
136
+
116
137
  session_id_from_header = extract_session_id
117
138
  return render_bad_request("Mcp-Session-Id header is required for DELETE requests.") unless session_id_from_header
118
139
 
119
- session = Server.session_store.load_session(session_id_from_header)
140
+ session = load_session(session_id_from_header)
120
141
  if session.nil?
121
142
  return render_not_found("Session not found.")
122
143
  elsif session.status == "closed"
123
- return head :no_content
144
+ return render_not_found("Session has been terminated.")
124
145
  end
125
146
 
126
- # Authenticate the request via gateway
127
- authenticate_gateway!
128
- return if performed?
147
+ @mcp_session = session
148
+ return unless configure_gateway_session!(session)
149
+ return unless validate_protocol_version_header(session)
129
150
 
130
151
  begin
131
152
  session.close!
@@ -139,23 +160,12 @@ module ActionMCP
139
160
 
140
161
  private
141
162
 
142
- # Validates the MCP-Protocol-Version header for non-initialization requests
163
+ # Validates the MCP-Protocol-Version header for requests after initialization.
143
164
  # Returns true if valid, renders error and returns false if invalid
144
- def validate_protocol_version_header
145
- # Skip validation for initialization-related requests
146
- return true if initialization_related_request?(jsonrpc_params)
147
-
148
- # Check for both case variations of the header (spec uses MCP-Protocol-Version)
165
+ def validate_protocol_version_header(session)
149
166
  header_version = request.headers["MCP-Protocol-Version"] || request.headers["mcp-protocol-version"]
150
- session = mcp_session
151
-
152
- # If header is missing, assume 2025-06-18 for backward compatibility as per spec
153
- if header_version.nil?
154
- ActionMCP.logger.debug "MCP-Protocol-Version header missing, assuming 2025-06-18 for backward compatibility"
155
- return true
156
- end
167
+ return true if header_version.nil? && session.protocol_version == ActionMCP::LATEST_VERSION
157
168
 
158
- # Handle array values (take the last one as per TypeScript SDK)
159
169
  header_version = header_version.last if header_version.is_a?(Array)
160
170
 
161
171
  # Check if the header version is supported
@@ -166,30 +176,27 @@ module ActionMCP
166
176
  return false
167
177
  end
168
178
 
169
- # If we have an initialized session, check if the header matches the negotiated version
170
- if session&.initialized?
171
- negotiated_version = session.protocol_version
172
- if header_version != negotiated_version
173
- ActionMCP.logger.warn "MCP-Protocol-Version mismatch: header=#{header_version}, negotiated=#{negotiated_version}"
174
- render_protocol_version_error("MCP-Protocol-Version header (#{header_version}) does not match negotiated version (#{negotiated_version})")
175
- return false
176
- end
179
+ negotiated_version = session.protocol_version
180
+ if header_version != negotiated_version
181
+ ActionMCP.logger.warn "MCP-Protocol-Version mismatch: header=#{header_version}, negotiated=#{negotiated_version}"
182
+ render_protocol_version_error("MCP-Protocol-Version header (#{header_version}) does not match negotiated version (#{negotiated_version})")
183
+ return false
177
184
  end
178
185
 
179
186
  true
180
187
  end
181
188
 
182
- # Finds an existing session based on header or param, or initializes a new one.
183
- # Note: This doesn't save the new session; that happens upon first use or explicitly.
184
- def find_or_initialize_session
185
- session_id = extract_session_id
186
- session_store = ActionMCP::Server.session_store
189
+ def load_session(session_id)
190
+ return unless session_id
187
191
 
188
- if session_id
189
- session_store.load_session(session_id)
190
- else
191
- session_store.create_session(nil, protocol_version: ActionMCP::DEFAULT_PROTOCOL_VERSION)
192
- end
192
+ ActionMCP::Server.session_store.load_session(session_id)
193
+ end
194
+
195
+ def create_session
196
+ ActionMCP::Server.session_store.create_session(
197
+ nil,
198
+ protocol_version: ActionMCP::DEFAULT_PROTOCOL_VERSION
199
+ )
193
200
  end
194
201
 
195
202
  # @return [String, nil] The extracted session ID or nil if not found.
@@ -199,30 +206,50 @@ module ActionMCP
199
206
 
200
207
  # Checks if the Accept headers for POST are valid.
201
208
  def post_accept_headers_valid?
202
- request.accepts.any? { |type| type.to_s == "application/json" }
209
+ accepted_media_types = Rack::Utils.q_values(request.get_header("HTTP_ACCEPT")).filter_map do |media_range, quality|
210
+ Rack::MediaType.type(media_range)&.downcase if quality.positive?
211
+ end
212
+
213
+ POST_ACCEPT_MEDIA_TYPES.all? { |media_type| accepted_media_types.include?(media_type) }
203
214
  end
204
215
 
205
216
  # Returns the appropriate error message for POST Accept header validation.
206
217
  def post_accept_headers_error_message
207
- "Client must accept 'application/json'"
218
+ "Not Acceptable: Client must accept both application/json and text/event-stream"
208
219
  end
209
220
 
210
- # Checks if the parsed body represents an 'initialize' request.
211
- def check_if_initialize_request(payload)
212
- return false unless payload.is_a?(JSON_RPC::Request) && !jsonrpc_params_batch?
221
+ def initialize_request?(payload)
222
+ payload.is_a?(JSON_RPC::Request) && payload.method == JsonRpcHandlerBase::Methods::INITIALIZE
223
+ end
213
224
 
214
- payload.method == "initialize"
225
+ def initialized_notification?(payload)
226
+ payload.is_a?(JSON_RPC::Notification) &&
227
+ payload.method == JsonRpcHandlerBase::Methods::NOTIFICATIONS_INITIALIZED
215
228
  end
216
229
 
217
- # Checks if the request is related to initialization (initialize or notifications/initialized)
218
- def initialization_related_request?(payload)
219
- return false unless payload.respond_to?(:method) && !jsonrpc_params_batch?
230
+ def ping_request?(payload)
231
+ payload.is_a?(JSON_RPC::Request) && payload.method == JsonRpcHandlerBase::Methods::PING
232
+ end
233
+
234
+ def lifecycle_allows?(payload, session, initialize_request)
235
+ return true if initialize_request
236
+
237
+ if initialized_notification?(payload)
238
+ return true if session.status == "initializing" && !session.initialized?
220
239
 
221
- %w[initialize notifications/initialized].include?(payload.method)
240
+ render_bad_request("Session is not awaiting an initialized notification.", request_id(payload))
241
+ return false
242
+ end
243
+
244
+ return true if session.initialized?
245
+ return true if session.status == "initializing" && ping_request?(payload)
246
+
247
+ render_bad_request("Session initialization is incomplete.", request_id(payload))
248
+ false
222
249
  end
223
250
 
224
251
  # Processes the results from the JsonRpcHandler.
225
- def process_handler_results(result, session, session_initially_missing, is_initialize_request)
252
+ def process_handler_results(result, session, is_initialize_request)
226
253
  # Handle empty result (notifications)
227
254
  return head :accepted if result.nil?
228
255
 
@@ -235,7 +262,10 @@ module ActionMCP
235
262
  result
236
263
  end
237
264
 
238
- add_session_header = is_initialize_request && session_initially_missing && session.persisted?
265
+ successful_initialize = is_initialize_request && result.is_a?(JSON_RPC::Response) && result.error.nil?
266
+ Server.session_store.delete_session(session.id) if is_initialize_request && !successful_initialize
267
+
268
+ add_session_header = successful_initialize && session.persisted?
239
269
  render_json_response(payload, session, add_session_header)
240
270
  end
241
271
 
@@ -258,7 +288,7 @@ module ActionMCP
258
288
  # Renders a 400 Bad Request response with a JSON-RPC-like error structure.
259
289
  def render_bad_request(message = "Bad Request", id = nil)
260
290
  id ||= extract_jsonrpc_id_from_request
261
- render json: { jsonrpc: "2.0", id: id, error: { code: -32_600, message: message } }
291
+ render json: { jsonrpc: "2.0", id: id, error: { code: -32_600, message: message } }, status: :bad_request
262
292
  end
263
293
 
264
294
  # Renders a 400 Bad Request response for protocol version errors as per MCP spec
@@ -270,30 +300,40 @@ module ActionMCP
270
300
  # Renders a 404 Not Found response with a JSON-RPC-like error structure.
271
301
  def render_not_found(message = "Not Found", id = nil)
272
302
  id ||= extract_jsonrpc_id_from_request
273
- render json: { jsonrpc: "2.0", id: id, error: { code: -32_001, message: message } }
303
+ render json: { jsonrpc: "2.0", id: id, error: { code: -32_001, message: message } }, status: :not_found
274
304
  end
275
305
 
276
306
  # Renders a 405 Method Not Allowed response.
277
307
  def render_method_not_allowed(message = "Method Not Allowed", id = nil)
278
308
  id ||= extract_jsonrpc_id_from_request
279
- render json: { jsonrpc: "2.0", id: id, error: { code: -32_601, message: message } }
309
+ render json: { jsonrpc: "2.0", id: id, error: { code: -32_601, message: message } }, status: :method_not_allowed
280
310
  end
281
311
 
282
312
  # Renders a 406 Not Acceptable response.
283
313
  def render_not_acceptable(message = "Not Acceptable", id = nil)
284
314
  id ||= extract_jsonrpc_id_from_request
285
- render json: { jsonrpc: "2.0", id: id, error: { code: -32_002, message: message } }
315
+ render json: { jsonrpc: "2.0", id: id, error: { code: -32_000, message: message } }, status: :not_acceptable
286
316
  end
287
317
 
288
318
  # Renders a 501 Not Implemented response.
289
319
  def render_not_implemented(message = "Not Implemented", id = nil)
290
320
  id ||= extract_jsonrpc_id_from_request
291
- render json: { jsonrpc: "2.0", id: id, error: { code: -32_003, message: message } }
321
+ render json: { jsonrpc: "2.0", id: id, error: { code: -32_003, message: message } }, status: :not_implemented
292
322
  end
293
323
 
294
324
  # Renders a 500 Internal Server Error response.
295
325
  def render_internal_server_error(message = "Internal Server Error", id = nil)
296
- render json: { jsonrpc: "2.0", id: id, error: { code: -32_000, message: message } }
326
+ render json: { jsonrpc: "2.0", id: id, error: { code: -32_603, message: message } }, status: :internal_server_error
327
+ end
328
+
329
+ def render_jsonrpc_error(symbol, message, id = nil)
330
+ error = JSON_RPC::JsonRpcError.new(symbol, message: message)
331
+ render json: JSON_RPC::Response.new(id: id, error: error), status: :ok
332
+ end
333
+
334
+ def render_notification_error(validation_error)
335
+ error = JSON_RPC::JsonRpcError.new(validation_error.code, message: validation_error.message)
336
+ render json: JSON_RPC::Response.new(id: nil, error: error), status: :bad_request
297
337
  end
298
338
 
299
339
  # Extract JSON-RPC ID from request
@@ -316,34 +356,51 @@ module ActionMCP
316
356
  end
317
357
  end
318
358
 
359
+ def request_id(payload)
360
+ payload.respond_to?(:id) ? payload.id : nil
361
+ end
362
+
319
363
  # Authenticates the request using the configured gateway
320
364
  def authenticate_gateway!
321
- # Skip authentication for initialization-related requests in POST method
322
- if request.post? && defined?(jsonrpc_params) && jsonrpc_params && initialization_related_request?(jsonrpc_params)
323
- return
324
- end
325
-
326
365
  gateway_class = ActionMCP.configuration.gateway_class
327
- return unless gateway_class # Skip if no gateway configured
366
+ return true unless gateway_class
328
367
 
329
368
  begin
330
- gateway = gateway_class.new(request)
331
- gateway.call
332
- gateway.configure_session(mcp_session)
369
+ @authenticated_gateway = gateway_class.new(request)
370
+ @authenticated_gateway.call
371
+ true
333
372
  rescue ActionMCP::UnauthorizedError => e
334
373
  render_unauthorized(e.message)
374
+ false
335
375
  rescue StandardError => e
336
376
  Rails.error.report(e, handled: true, severity: :error)
337
377
  render_unauthorized("Authentication system error")
378
+ false
338
379
  end
339
380
  end
340
381
 
382
+ def configure_gateway_session!(session)
383
+ return true unless @authenticated_gateway
384
+
385
+ @authenticated_gateway.configure_session(session)
386
+ true
387
+ rescue ActionMCP::UnauthorizedError => e
388
+ render_unauthorized(e.message)
389
+ false
390
+ rescue StandardError => e
391
+ Rails.error.report(e, handled: true, severity: :error)
392
+ render_unauthorized("Authentication system error")
393
+ false
394
+ end
395
+
341
396
  # Renders an unauthorized response
342
397
  def render_unauthorized(message = "Unauthorized", id = nil)
343
398
  id ||= extract_jsonrpc_id_from_request
344
399
 
345
- # Return JSON-RPC error with 200 status as per MCP specification
346
- render json: { jsonrpc: "2.0", id: id, error: { code: -32_000, message: message } }
400
+ challenge = "Bearer"
401
+ challenge += ' error="invalid_token"' if request.headers["Authorization"].present?
402
+ response.headers["WWW-Authenticate"] = challenge
403
+ render json: { jsonrpc: "2.0", id: id, error: { code: -32_000, message: message } }, status: :unauthorized
347
404
  end
348
405
  end
349
406
  end
@@ -18,5 +18,6 @@ module ActionMCP
18
18
  #
19
19
  # Not routed. Not intended to be subclassed or used directly by host apps.
20
20
  class MCPAppRenderer < ActionController::Base
21
+ helper ActionMCP::AppsHelper
21
22
  end
22
23
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ # View helpers for MCP Apps UI templates rendered via `render_ui`.
5
+ module AppsHelper
6
+ # Inlines the vendored @modelcontextprotocol/ext-apps browser bundle and a
7
+ # small bootstrap into a single <script type="module"> element.
8
+ #
9
+ # UI view HTML is delivered to hosts as resource text (not over HTTP), so
10
+ # the bridge must ship inline — external scripts would require CSP-declared
11
+ # origins and a reachable asset host.
12
+ #
13
+ # The bootstrap exposes `globalThis.ActionMCP`:
14
+ # App - the raw ext-apps App class
15
+ # bridgeVersion - vendored package version
16
+ # connect(opts) - registers opts.handlers on a new App instance BEFORE
17
+ # calling connect(), per the ext-apps handshake contract,
18
+ # then resolves with the connected app.
19
+ #
20
+ # Views run their own module script after this tag; module scripts execute
21
+ # in document order, so `ActionMCP` is always defined by then.
22
+ #
23
+ # @example In a view
24
+ # <%= mcp_app_bridge_tag %>
25
+ # <script type="module">
26
+ # const app = await ActionMCP.connect({
27
+ # handlers: { ontoolresult: (result) => { ... } }
28
+ # });
29
+ # </script>
30
+ def mcp_app_bridge_tag(app_name: "action-mcp-view", app_version: "1.0.0")
31
+ default_app_info = { name: app_name, version: app_version }.to_json
32
+ bootstrap = <<~JS
33
+ globalThis.ActionMCP = Object.freeze({
34
+ App,
35
+ bridgeVersion: #{Apps::BRIDGE_VERSION.to_json},
36
+ async connect({ appInfo, capabilities, handlers } = {}) {
37
+ const app = new App(appInfo ?? #{default_app_info}, capabilities ?? {});
38
+ if (handlers) Object.assign(app, handlers);
39
+ await app.connect();
40
+ return app;
41
+ }
42
+ });
43
+ JS
44
+
45
+ content_tag(:script, "#{Apps.bridge_source}\n#{bootstrap}".html_safe, type: "module")
46
+ end
47
+ end
48
+ end