actionmcp 0.111.0 → 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.
- checksums.yaml +4 -4
- data/README.md +26 -18
- data/Rakefile +3 -0
- data/app/controllers/action_mcp/application_controller.rb +166 -109
- data/app/controllers/action_mcp/mcp_app_renderer.rb +1 -0
- data/app/helpers/action_mcp/apps_helper.rb +48 -0
- data/app/jobs/action_mcp/tool_execution_job.rb +67 -35
- data/app/models/action_mcp/session/message.rb +8 -3
- data/app/models/action_mcp/session/task.rb +15 -5
- data/app/models/action_mcp/session.rb +93 -19
- data/config/routes.rb +1 -1
- data/lib/action_mcp/apps/javascript/ext_apps.LICENSE +216 -0
- data/lib/action_mcp/apps/javascript/ext_apps.js +78 -0
- data/lib/action_mcp/apps/view_manifest.rb +170 -0
- data/lib/action_mcp/apps.rb +17 -2
- data/lib/action_mcp/configuration.rb +14 -30
- data/lib/action_mcp/content/audio.rb +4 -1
- data/lib/action_mcp/content/base.rb +3 -1
- data/lib/action_mcp/content/image.rb +4 -1
- data/lib/action_mcp/content/resource.rb +15 -14
- data/lib/action_mcp/content/resource_link.rb +15 -5
- data/lib/action_mcp/content/text.rb +2 -1
- data/lib/action_mcp/content/validation.rb +253 -0
- data/lib/action_mcp/content.rb +3 -1
- data/lib/action_mcp/dev/runner.rb +203 -0
- data/lib/action_mcp/engine.rb +24 -2
- data/lib/action_mcp/json_rpc_handler_base.rb +7 -12
- data/lib/action_mcp/logging/logger.rb +16 -11
- data/lib/action_mcp/logging.rb +34 -1
- data/lib/action_mcp/middleware/origin_validation.rb +9 -13
- data/lib/action_mcp/output_schema_builder.rb +2 -0
- data/lib/action_mcp/prompt_response.rb +12 -2
- data/lib/action_mcp/protocol_validator.rb +513 -0
- data/lib/action_mcp/renderable.rb +4 -4
- data/lib/action_mcp/resource.rb +15 -11
- data/lib/action_mcp/resource_template.rb +105 -90
- data/lib/action_mcp/resource_templates_registry.rb +31 -97
- data/lib/action_mcp/schema_validator.rb +43 -0
- data/lib/action_mcp/server/base_session.rb +134 -28
- data/lib/action_mcp/server/base_session_store.rb +1 -1
- data/lib/action_mcp/server/capabilities.rb +10 -30
- data/lib/action_mcp/server/client_notifications.rb +63 -0
- data/lib/action_mcp/server/completions.rb +68 -0
- data/lib/action_mcp/server/elicitation.rb +47 -25
- data/lib/action_mcp/server/elicitation_request.rb +136 -51
- data/lib/action_mcp/server/handlers/logging_handler.rb +7 -5
- data/lib/action_mcp/server/handlers/prompt_handler.rb +12 -1
- data/lib/action_mcp/server/handlers/resource_handler.rb +41 -7
- data/lib/action_mcp/server/handlers/task_handler.rb +26 -0
- data/lib/action_mcp/server/handlers/tool_handler.rb +8 -0
- data/lib/action_mcp/server/json_rpc_handler.rb +5 -21
- data/lib/action_mcp/server/messaging_service.rb +3 -1
- data/lib/action_mcp/server/prompts.rb +5 -3
- data/lib/action_mcp/server/resources.rb +35 -2
- data/lib/action_mcp/server/response_collector.rb +5 -1
- data/lib/action_mcp/server/roots.rb +15 -2
- data/lib/action_mcp/server/sampling.rb +36 -1
- data/lib/action_mcp/server/sampling_request.rb +317 -55
- data/lib/action_mcp/server/tasks.rb +27 -45
- data/lib/action_mcp/server/tool_result.rb +27 -0
- data/lib/action_mcp/server/tools.rb +13 -8
- data/lib/action_mcp/server/transport_handler.rb +3 -0
- data/lib/action_mcp/server/url_elicitation_request.rb +13 -0
- data/lib/action_mcp/tool.rb +133 -199
- data/lib/action_mcp/tool_response.rb +35 -4
- data/lib/action_mcp/tools_registry.rb +1 -1
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +2 -5
- data/lib/generators/action_mcp/identifier/identifier_generator.rb +1 -1
- data/lib/generators/action_mcp/identifier/templates/identifier.rb.erb +1 -1
- data/lib/generators/action_mcp/install/templates/bin/mcp +16 -4
- data/lib/generators/action_mcp/install/templates/mcp/config.ru.tt +2 -2
- data/lib/generators/action_mcp/install/templates/mcp.yml +4 -0
- data/lib/generators/action_mcp/resource_template/resource_template_generator.rb +18 -1
- data/lib/generators/action_mcp/resource_template/templates/resource_template_ui.rb.erb +27 -0
- data/lib/generators/action_mcp/tool/templates/tool.rb.erb +6 -0
- data/lib/generators/action_mcp/tool/tool_generator.rb +2 -0
- data/lib/generators/action_mcp/view/templates/resource_template.rb.erb +16 -0
- data/lib/generators/action_mcp/view/templates/view.html.erb +27 -0
- data/lib/generators/action_mcp/view/view_generator.rb +57 -0
- data/lib/tasks/action_mcp_apps.rake +17 -0
- data/lib/tasks/action_mcp_tasks.rake +135 -74
- metadata +47 -17
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5959fcdcfdeed27a18bc9723f51bbfb3699d0983172875e4c8dc9cc6873eadf4
|
|
4
|
+
data.tar.gz: 04ce30167f6377c54dc92e574dc50d027bb9a6f76355d3c96bde33de99ada463
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
- **
|
|
33
|
-
- **
|
|
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
|
-
|
|
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
|
-
-
|
|
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-
|
|
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:**
|
|
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://
|
|
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
|
|
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
|
@@ -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-
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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,
|
|
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
|
|
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 =
|
|
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
|
|
144
|
+
return render_not_found("Session has been terminated.")
|
|
124
145
|
end
|
|
125
146
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return
|
|
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
|
|
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
|
|
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
|
-
|
|
170
|
-
if
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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.
|
|
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
|
|
218
|
+
"Not Acceptable: Client must accept both application/json and text/event-stream"
|
|
208
219
|
end
|
|
209
220
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
221
|
+
def initialize_request?(payload)
|
|
222
|
+
payload.is_a?(JSON_RPC::Request) && payload.method == JsonRpcHandlerBase::Methods::INITIALIZE
|
|
223
|
+
end
|
|
213
224
|
|
|
214
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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: -
|
|
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: -
|
|
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
|
|
366
|
+
return true unless gateway_class
|
|
328
367
|
|
|
329
368
|
begin
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
346
|
-
|
|
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
|
|
@@ -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
|