actionmcp 0.71.1 → 0.80.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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +187 -16
  3. data/app/controllers/action_mcp/application_controller.rb +64 -49
  4. data/app/models/action_mcp/session/message.rb +31 -20
  5. data/app/models/action_mcp/session/resource.rb +35 -20
  6. data/app/models/action_mcp/session/sse_event.rb +23 -17
  7. data/app/models/action_mcp/session/subscription.rb +22 -15
  8. data/app/models/action_mcp/session.rb +71 -113
  9. data/config/routes.rb +0 -11
  10. data/db/migrate/20250512154359_consolidated_migration.rb +3 -3
  11. data/db/migrate/20250715070713_add_consents_to_action_mcp_sess.rb +7 -0
  12. data/db/migrate/20250727000001_remove_oauth_support.rb +59 -0
  13. data/lib/action_mcp/base_response.rb +1 -1
  14. data/lib/action_mcp/client/base.rb +9 -11
  15. data/lib/action_mcp/client/elicitation.rb +4 -4
  16. data/lib/action_mcp/client/json_rpc_handler.rb +11 -13
  17. data/lib/action_mcp/client/streamable_http_transport.rb +19 -74
  18. data/lib/action_mcp/client.rb +6 -26
  19. data/lib/action_mcp/configuration.rb +65 -63
  20. data/lib/action_mcp/engine.rb +1 -10
  21. data/lib/action_mcp/filtered_logger.rb +3 -7
  22. data/lib/action_mcp/gateway.rb +7 -11
  23. data/lib/action_mcp/gateway_identifier.rb +187 -3
  24. data/lib/action_mcp/gateway_identifiers/api_key_identifier.rb +56 -0
  25. data/lib/action_mcp/gateway_identifiers/devise_identifier.rb +34 -0
  26. data/lib/action_mcp/gateway_identifiers/request_env_identifier.rb +58 -0
  27. data/lib/action_mcp/gateway_identifiers/warden_identifier.rb +38 -0
  28. data/lib/action_mcp/gateway_identifiers.rb +26 -0
  29. data/lib/action_mcp/json_rpc_handler_base.rb +0 -2
  30. data/lib/action_mcp/prompt.rb +2 -0
  31. data/lib/action_mcp/renderable.rb +1 -1
  32. data/lib/action_mcp/resource_template.rb +6 -2
  33. data/lib/action_mcp/server/{memory_session.rb → base_session.rb} +41 -26
  34. data/lib/action_mcp/server/base_session_store.rb +86 -0
  35. data/lib/action_mcp/server/capabilities.rb +2 -1
  36. data/lib/action_mcp/server/elicitation.rb +3 -9
  37. data/lib/action_mcp/server/error_handling.rb +14 -1
  38. data/lib/action_mcp/server/handlers/router.rb +31 -0
  39. data/lib/action_mcp/server/json_rpc_handler.rb +2 -5
  40. data/lib/action_mcp/server/{messaging.rb → messaging_service.rb} +38 -14
  41. data/lib/action_mcp/server/prompts.rb +4 -4
  42. data/lib/action_mcp/server/resources.rb +23 -4
  43. data/lib/action_mcp/server/session_store_factory.rb +1 -1
  44. data/lib/action_mcp/server/solid_mcp_adapter.rb +9 -10
  45. data/lib/action_mcp/server/tools.rb +62 -43
  46. data/lib/action_mcp/server/transport_handler.rb +2 -4
  47. data/lib/action_mcp/server/volatile_session_store.rb +1 -93
  48. data/lib/action_mcp/tagged_stream_logging.rb +2 -2
  49. data/lib/action_mcp/test_helper/progress_notification_assertions.rb +4 -4
  50. data/lib/action_mcp/test_helper/session_store_assertions.rb +5 -1
  51. data/lib/action_mcp/tool.rb +48 -37
  52. data/lib/action_mcp/types/float_array_type.rb +5 -3
  53. data/lib/action_mcp/version.rb +1 -1
  54. data/lib/action_mcp.rb +2 -7
  55. data/lib/generators/action_mcp/identifier/identifier_generator.rb +189 -0
  56. data/lib/generators/action_mcp/identifier/templates/identifier.rb.erb +35 -0
  57. data/lib/generators/action_mcp/install/install_generator.rb +1 -1
  58. data/lib/generators/action_mcp/install/templates/application_gateway.rb +86 -36
  59. data/lib/generators/action_mcp/install/templates/mcp.yml +4 -21
  60. data/lib/tasks/action_mcp_tasks.rake +7 -5
  61. metadata +18 -100
  62. data/app/controllers/action_mcp/oauth/endpoints_controller.rb +0 -264
  63. data/app/controllers/action_mcp/oauth/metadata_controller.rb +0 -129
  64. data/app/controllers/action_mcp/oauth/registration_controller.rb +0 -206
  65. data/app/models/action_mcp/oauth_client.rb +0 -157
  66. data/app/models/action_mcp/oauth_token.rb +0 -141
  67. data/db/migrate/20250608112101_add_oauth_to_sessions.rb +0 -19
  68. data/db/migrate/20250708105124_create_action_mcp_oauth_clients.rb +0 -42
  69. data/db/migrate/20250708105226_create_action_mcp_oauth_tokens.rb +0 -37
  70. data/lib/action_mcp/client/jwt_client_provider.rb +0 -134
  71. data/lib/action_mcp/client/oauth_client_provider/memory_storage.rb +0 -47
  72. data/lib/action_mcp/client/oauth_client_provider.rb +0 -234
  73. data/lib/action_mcp/jwt_decoder.rb +0 -26
  74. data/lib/action_mcp/jwt_identifier.rb +0 -28
  75. data/lib/action_mcp/none_identifier.rb +0 -19
  76. data/lib/action_mcp/o_auth_identifier.rb +0 -34
  77. data/lib/action_mcp/oauth/active_record_storage.rb +0 -183
  78. data/lib/action_mcp/oauth/error.rb +0 -79
  79. data/lib/action_mcp/oauth/memory_storage.rb +0 -134
  80. data/lib/action_mcp/oauth/middleware.rb +0 -133
  81. data/lib/action_mcp/oauth/provider.rb +0 -426
  82. data/lib/action_mcp/oauth.rb +0 -12
  83. data/lib/action_mcp/omniauth/mcp_strategy.rb +0 -176
  84. data/lib/action_mcp/server/notifications.rb +0 -58
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 203f9e44a8802e007a19f41ddb89e2961fae87fb14bc0157e398dd6b80561cc3
4
- data.tar.gz: d644c46fe4e73c1c5532d14f2e1541d511e4311479c15ef5d946aa135c4f290d
3
+ metadata.gz: ea96963047c8bb5e9fa9fd88163a14798c8780a531fa1600e3681bd92ca1b4fe
4
+ data.tar.gz: aae9fc897bd554fba0442735b184d9eac03a381c3b07e052869ce76ce714555d
5
5
  SHA512:
6
- metadata.gz: c9472a20f2aafc0c4ac4b74d8924e741b4cb94dd69154d44b060d2a75fcc01edcda857be40611c7c24b6d1c4ee5410705b776999534c35521301fef737e57d35
7
- data.tar.gz: 78afc85939383e260a89726dbc10c516a4c7da3dded794d55545c51ed98ba2179b433d402a552c1964f15a95c9e51c0e2e6ac531f18e1e145ae4a9ebe47b893e
6
+ metadata.gz: 47947a7bba2e39a33b1793cbf8af4a38dcf26e0eebcc19989b143d4279f8f71fc05c646169d4739dd8e52f23c2f34df5a232d4cd414e6872cc636ab4c5a22ae5
7
+ data.tar.gz: 8e2b06ddaf6b1174035dc3e4a6315aaf8d624ac5a096d647f252c40c533f5d20ebf02b3b8b5355d81617de98dfccf63d4342ec540558f8dbc5072043aee11c4a
data/README.md CHANGED
@@ -24,7 +24,15 @@ 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**. 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).
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:
28
+
29
+ - **JSON-RPC 2.0** transport layer
30
+ - **Capability negotiation** during initialization
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
34
+
35
+ 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
36
 
29
37
  *Don't Panic: The guide contains everything you need to know about surviving MCP protocol versions.*
30
38
 
@@ -42,25 +50,27 @@ In short, ActionMCP helps you build an MCP server (the component that exposes ca
42
50
 
43
51
  To start using ActionMCP, add it to your project:
44
52
 
45
- - **Using Bundler (Rails or Ruby projects):** Add the gem to your Gemfile and run bundle install:
53
+ ```bash
54
+ # Add gem to your Gemfile
55
+ $ bundle add actionmcp
46
56
 
47
- ```bash
48
- $ bundle add actionmcp
49
- ```
57
+ # Install dependencies
58
+ bundle install
50
59
 
51
- After adding the gem, run the install generator to set up the basic ActionMCP structure:
60
+ # Copy migrations from the engine
61
+ bin/rails action_mcp:install:migrations
52
62
 
53
- ```bash
54
- bundle install
55
- bin/rails action_mcp:install:migrations # Copy migrations from the engine
56
- bin/rails generate action_mcp:install # Creates base classes and configuration
57
- bin/rails db:migrate # Creates necessary database tables
63
+ # Generate base classes and configuration
64
+ bin/rails generate action_mcp:install
65
+
66
+ # Create necessary database tables
67
+ bin/rails db:migrate
58
68
  ```
59
69
 
60
70
  The `action_mcp:install` generator will:
61
71
  - Create base application classes (ApplicationGateway, ApplicationMCPTool, etc.)
62
72
  - Generate the MCP configuration file (`config/mcp.yml`)
63
- - Set up the basic directory structure for MCP components
73
+ - Set up the basic directory structure for MCP components (`app/mcp/`)
64
74
 
65
75
  Database migrations are copied separately using `bin/rails action_mcp:install:migrations`.
66
76
 
@@ -127,6 +137,7 @@ Key features:
127
137
  - Return multiple response types (text, images, errors)
128
138
  - Progressive responses with multiple render calls
129
139
  - Automatic input validation based on property definitions
140
+ - **Consent management for sensitive operations**
130
141
 
131
142
  **Example:**
132
143
 
@@ -160,6 +171,47 @@ class CalculateSumTool < ApplicationMCPTool
160
171
  end
161
172
  ```
162
173
 
174
+ #### Consent Management
175
+
176
+ For tools that perform sensitive operations (file system access, database modifications, external API calls), you can require explicit user consent:
177
+
178
+ ```ruby
179
+ class FileSystemTool < ApplicationMCPTool
180
+ tool_name "read_file"
181
+ description "Read contents of a file"
182
+
183
+ # Require explicit consent before execution
184
+ requires_consent!
185
+
186
+ property :file_path, type: "string", description: "Path to file", required: true
187
+
188
+ def perform
189
+ # This code only runs after user grants consent
190
+ content = File.read(file_path)
191
+ render(text: "File contents: #{content}")
192
+ end
193
+ end
194
+ ```
195
+
196
+ **Consent Flow:**
197
+ 1. When a consent-required tool is called without consent, it returns a JSON-RPC error with code `-32002`
198
+ 2. The client must explicitly grant consent for the specific tool
199
+ 3. Once granted, the tool can execute normally for that session
200
+ 4. Consent is session-scoped and can be revoked at any time
201
+
202
+ **Managing Consent:**
203
+
204
+ ```ruby
205
+ # Check if consent is granted
206
+ session.consent_granted_for?("read_file")
207
+
208
+ # Grant consent for a tool
209
+ session.grant_consent("read_file")
210
+
211
+ # Revoke consent
212
+ session.revoke_consent("read_file")
213
+ ```
214
+
163
215
  Tools can be executed by instantiating them and calling the `call` method:
164
216
 
165
217
  ```ruby
@@ -524,7 +576,7 @@ This will create:
524
576
 
525
577
  ActionMCP provides a Gateway system similar to ActionCable's Connection for handling authentication. The Gateway allows you to authenticate users and make them available throughout your MCP components.
526
578
 
527
- ActionMCP supports multiple authentication methods including OAuth 2.1, JWT tokens, and no authentication for development. For detailed OAuth 2.1 configuration and usage, see the [OAuth Authentication Guide](OAUTH.md).
579
+ 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.
528
580
 
529
581
  ### Creating an ApplicationGateway
530
582
 
@@ -751,17 +803,97 @@ class ToolTest < ActiveSupport::TestCase
751
803
  end
752
804
  ```
753
805
 
806
+ The TestHelper provides several assertion methods:
807
+ - `assert_tool_findable(name)` - Verifies a tool exists and is registered
808
+ - `assert_prompt_findable(name)` - Verifies a prompt exists and is registered
809
+ - `execute_tool(name, **args)` - Executes a tool with arguments
810
+ - `execute_prompt(name, **args)` - Executes a prompt with arguments
811
+ - `assert_tool_output(result, expected)` - Asserts tool output matches expected text
812
+ - `assert_prompt_output(result)` - Extracts and returns prompt output for assertions
813
+
754
814
  ## Inspecting Your MCP Server
755
815
 
756
816
  You can use the MCP Inspector to test your server implementation:
757
817
 
758
818
  ```bash
759
- npx @modelcontextprotocol/inspector
819
+ # Start your MCP server
820
+ bundle exec rails s -c mcp.ru -p 62770
821
+
822
+ # In another terminal, run the inspector
823
+ npx @modelcontextprotocol/inspector --url http://localhost:62770
824
+ ```
825
+
826
+ The MCP Inspector provides an interactive interface to:
827
+ - Test tool executions with custom arguments
828
+ - Validate prompt responses
829
+ - Inspect resource templates and their outputs
830
+ - Debug protocol compliance and error handling
831
+
832
+ ## Development Commands
833
+
834
+ ActionMCP includes several rake tasks for development and debugging:
835
+
836
+ ```bash
837
+ # List all MCP components
838
+ bundle exec rails action_mcp:list
839
+
840
+ # List specific component types
841
+ bundle exec rails action_mcp:list_tools
842
+ bundle exec rails action_mcp:list_prompts
843
+ bundle exec rails action_mcp:list_resources
844
+ bundle exec rails action_mcp:list_profiles
845
+
846
+ # Show configuration and statistics
847
+ bundle exec rails action_mcp:info
848
+ bundle exec rails action_mcp:stats
849
+
850
+ # Show profile configuration
851
+ bundle exec rails action_mcp:show_profile[profile_name]
760
852
  ```
761
853
 
762
- The default path will be http://localhost:3000/action_mcp
854
+ ## Error Handling and Troubleshooting
855
+
856
+ ActionMCP provides comprehensive error handling following the JSON-RPC 2.0 specification:
857
+
858
+ ### Error Codes
859
+
860
+ - **-32601**: Method not found - The requested method doesn't exist
861
+ - **-32002**: Consent required - Tool requires user consent to execute
862
+ - **-32603**: Internal error - Server encountered an unexpected error
863
+ - **-32600**: Invalid request - The request is malformed
864
+
865
+ ### Context-Aware Error Messages
763
866
 
764
- Here's a section you can add to explain the profile system in ActionMCP:
867
+ Tools should return clear error messages to the LLM using the `render` method:
868
+
869
+ ```ruby
870
+ class MyTool < ApplicationMCPTool
871
+ def perform
872
+ # Check for error conditions and return clear messages
873
+ if some_error_condition?
874
+ render(error: ["Clear error message for the LLM"])
875
+ return
876
+ end
877
+
878
+ # Normal processing
879
+ render(text: "Success message")
880
+ end
881
+ end
882
+ ```
883
+
884
+ ### Common Issues
885
+
886
+ 1. **Session not found**: Ensure sessions are properly created and saved in the session store
887
+ 2. **Tool not registered**: Verify tools are properly defined and inherit from ApplicationMCPTool
888
+ 3. **Consent required**: Grant consent using `session.grant_consent(tool_name)`
889
+ 4. **Middleware conflicts**: Use `mcp_vanilla.ru` to avoid web-specific middleware
890
+
891
+ ### Debugging Tips
892
+
893
+ - Check server logs for detailed error information
894
+ - Use `bundle exec rails action_mcp:info` to verify configuration
895
+ - Test with MCP Inspector to isolate protocol issues
896
+ - Ensure proper session management in production environments
765
897
 
766
898
  ## Profiles
767
899
 
@@ -875,3 +1007,42 @@ Profiles are particularly useful for:
875
1007
  5. **Progressive enhancement**: Start with a minimal profile and gradually add capabilities
876
1008
 
877
1009
  By leveraging profiles, you can maintain a single ActionMCP codebase while providing tailored MCP capabilities for different contexts.
1010
+
1011
+ ## Client Usage
1012
+
1013
+ ActionMCP includes a client for connecting to remote MCP servers. The client handles session management, protocol negotiation, and provides a simple API for interacting with MCP servers.
1014
+
1015
+ For comprehensive client documentation, including examples, session management, transport configuration, and API usage, see [CLIENTUSAGE.md](CLIENTUSAGE.md).
1016
+
1017
+ ## Production Considerations
1018
+
1019
+ ### Security
1020
+
1021
+ - **Never expose sensitive data** through MCP components
1022
+ - **Use authentication** via Gateway for production deployments
1023
+ - **Implement proper authorization** in your tools and prompts
1024
+ - **Validate all inputs** using property definitions and Rails validations
1025
+ - **Use consent management** for sensitive operations
1026
+
1027
+ ### Performance
1028
+
1029
+ - **Configure appropriate thread pools** for high-traffic scenarios
1030
+ - **Use Redis or SolidMCP** for production pub/sub
1031
+ - **Choose ActiveRecord session store** for session persistence
1032
+ - **Monitor session cleanup** to prevent memory leaks
1033
+ - **Use profiles** to limit exposed capabilities
1034
+
1035
+ ### Monitoring
1036
+
1037
+ - **Enable logging** and configure appropriate log levels
1038
+ - **Monitor session statistics** using `action_mcp:stats`
1039
+ - **Track tool usage** and performance metrics
1040
+ - **Set up alerts** for error rates and response times
1041
+
1042
+ ### Deployment
1043
+
1044
+ - **Use Falcon** for optimal performance with streaming workloads
1045
+ - **Deploy on dedicated ports** or Unix sockets
1046
+ - **Use reverse proxies** (Nginx, Apache) for SSL termination
1047
+ - **Implement health checks** for your MCP endpoints
1048
+ - **Use `mcp_vanilla.ru`** to avoid middleware conflicts
@@ -12,7 +12,6 @@ module ActionMCP
12
12
  include ActionController::Live
13
13
  include ActionController::Instrumentation
14
14
 
15
-
16
15
  # Provides the ActionMCP::Session for the current request.
17
16
  # Handles finding existing sessions via header/param or initializing a new one.
18
17
  # Specific controllers/handlers might need to enforce session ID presence based on context.
@@ -29,7 +28,9 @@ module ActionMCP
29
28
  end
30
29
 
31
30
  # Handles GET requests for establishing server-initiated SSE streams (2025-03-26 spec).
32
- # @route GET /
31
+ # <rails-lens:routes:begin>
32
+ # ROUTE: /, name: mcp_get, via: GET
33
+ # <rails-lens:routes:end>
33
34
  def show
34
35
  unless request.accepts.any? { |type| type.to_s == "text/event-stream" }
35
36
  return render_not_acceptable("Client must accept 'text/event-stream' for GET requests.")
@@ -52,7 +53,9 @@ module ActionMCP
52
53
  return if performed?
53
54
 
54
55
  last_event_id = request.headers["Last-Event-ID"].presence
55
- Rails.logger.info "Unified SSE (GET): Resuming from Last-Event-ID: #{last_event_id}" if last_event_id && ActionMCP.configuration.verbose_logging
56
+ if last_event_id && ActionMCP.configuration.verbose_logging
57
+ Rails.logger.info "Unified SSE (GET): Resuming from Last-Event-ID: #{last_event_id}"
58
+ end
56
59
 
57
60
  response.headers["Content-Type"] = "text/event-stream"
58
61
  response.headers["X-Accel-Buffering"] = "no"
@@ -61,7 +64,9 @@ module ActionMCP
61
64
  # Add MCP-Protocol-Version header for established sessions
62
65
  response.headers["MCP-Protocol-Version"] = session.protocol_version
63
66
 
64
- Rails.logger.info "Unified SSE (GET): Starting stream for session: #{session.id}" if ActionMCP.configuration.verbose_logging
67
+ if ActionMCP.configuration.verbose_logging
68
+ Rails.logger.info "Unified SSE (GET): Starting stream for session: #{session.id}"
69
+ end
65
70
 
66
71
  sse = SSE.new(response.stream)
67
72
  listener = SSEListener.new(session)
@@ -85,12 +90,16 @@ module ActionMCP
85
90
  begin
86
91
  missed_events = session.get_sse_events_after(last_event_id.to_i)
87
92
  if missed_events.any?
88
- Rails.logger.info "Unified SSE (GET): Sending #{missed_events.size} missed events for session: #{session.id}" if ActionMCP.configuration.verbose_logging
93
+ if ActionMCP.configuration.verbose_logging
94
+ Rails.logger.info "Unified SSE (GET): Sending #{missed_events.size} missed events for session: #{session.id}"
95
+ end
89
96
  missed_events.each do |event|
90
97
  sse.write(event.to_sse)
91
98
  end
92
- else
93
- Rails.logger.info "Unified SSE (GET): No missed events to send for session: #{session.id}" if ActionMCP.configuration.verbose_logging
99
+ elsif ActionMCP.configuration.verbose_logging
100
+ if ActionMCP.configuration.verbose_logging
101
+ Rails.logger.info "Unified SSE (GET): No missed events to send for session: #{session.id}"
102
+ end
94
103
  end
95
104
  rescue StandardError => e
96
105
  Rails.logger.error "Unified SSE (GET): Error sending missed events: #{e.message}"
@@ -116,7 +125,9 @@ module ActionMCP
116
125
  Rails.logger.warn "Unified SSE (GET): Heartbeat timed out for session: #{session.id}, closing."
117
126
  connection_active.make_false
118
127
  rescue StandardError => e
119
- Rails.logger.debug "Unified SSE (GET): Heartbeat error for session: #{session.id}: #{e.message}" if ActionMCP.configuration.verbose_logging
128
+ if ActionMCP.configuration.verbose_logging
129
+ Rails.logger.debug "Unified SSE (GET): Heartbeat error for session: #{session.id}: #{e.message}"
130
+ end
120
131
  connection_active.make_false
121
132
  end
122
133
  else
@@ -127,11 +138,15 @@ module ActionMCP
127
138
  heartbeat_task = Concurrent::ScheduledTask.execute(heartbeat_interval, &heartbeat_sender)
128
139
  sleep 0.1 while connection_active.true? && !response.stream.closed?
129
140
  rescue ActionController::Live::ClientDisconnected, IOError => e
130
- Rails.logger.debug "Unified SSE (GET): Client disconnected for session: #{session&.id}: #{e.message}" if ActionMCP.configuration.verbose_logging
141
+ if ActionMCP.configuration.verbose_logging
142
+ Rails.logger.debug "Unified SSE (GET): Client disconnected for session: #{session&.id}: #{e.message}"
143
+ end
131
144
  rescue StandardError => e
132
145
  Rails.logger.error "Unified SSE (GET): Unexpected error for session: #{session&.id}: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}"
133
146
  ensure
134
- Rails.logger.debug "Unified SSE (GET): Cleaning up connection for session: #{session&.id}" if ActionMCP.configuration.verbose_logging
147
+ if ActionMCP.configuration.verbose_logging
148
+ Rails.logger.debug "Unified SSE (GET): Cleaning up connection for session: #{session&.id}"
149
+ end
135
150
  heartbeat_active&.make_false
136
151
  heartbeat_task&.cancel
137
152
  listener&.stop
@@ -145,7 +160,9 @@ module ActionMCP
145
160
  end
146
161
 
147
162
  # Handles POST requests containing client JSON-RPC messages according to 2025-03-26 spec.
148
- # @route POST /mcp
163
+ # <rails-lens:routes:begin>
164
+ # ROUTE: /, name: mcp_post, via: POST
165
+ # <rails-lens:routes:end>
149
166
  def create
150
167
  unless post_accept_headers_valid?
151
168
  id = extract_jsonrpc_id_from_request
@@ -153,9 +170,7 @@ module ActionMCP
153
170
  end
154
171
 
155
172
  # Reject JSON-RPC batch requests as per MCP 2025-06-18 spec
156
- if jsonrpc_params_batch?
157
- return render_bad_request("JSON-RPC batch requests are not supported", nil)
158
- end
173
+ return render_bad_request("JSON-RPC batch requests are not supported", nil) if jsonrpc_params_batch?
159
174
 
160
175
  is_initialize_request = check_if_initialize_request(jsonrpc_params)
161
176
  session_initially_missing = extract_session_id.nil?
@@ -197,7 +212,9 @@ module ActionMCP
197
212
  result = json_rpc_handler.call(jsonrpc_params)
198
213
  process_handler_results(result, session, session_initially_missing, is_initialize_request)
199
214
  rescue ActionController::Live::ClientDisconnected, IOError => e
200
- Rails.logger.debug "Unified SSE (POST): Client disconnected during response: #{e.message}" if ActionMCP.configuration.verbose_logging
215
+ if ActionMCP.configuration.verbose_logging
216
+ Rails.logger.debug "Unified SSE (POST): Client disconnected during response: #{e.message}"
217
+ end
201
218
  begin
202
219
  response.stream&.close
203
220
  rescue StandardError
@@ -205,12 +222,18 @@ module ActionMCP
205
222
  end
206
223
  rescue StandardError => e
207
224
  Rails.logger.error "Unified POST Error: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}"
208
- id = jsonrpc_params.respond_to?(:id) ? jsonrpc_params.id : nil rescue nil
225
+ id = begin
226
+ jsonrpc_params.respond_to?(:id) ? jsonrpc_params.id : nil
227
+ rescue StandardError
228
+ nil
229
+ end
209
230
  render_internal_server_error("An unexpected error occurred.", id) unless performed?
210
231
  end
211
232
 
212
233
  # Handles DELETE requests for session termination (2025-03-26 spec).
213
- # @route DELETE /
234
+ # <rails-lens:routes:begin>
235
+ # ROUTE: /, name: mcp_delete, via: DELETE
236
+ # <rails-lens:routes:end>
214
237
  def destroy
215
238
  session_id_from_header = extract_session_id
216
239
  return render_bad_request("Mcp-Session-Id header is required for DELETE requests.") unless session_id_from_header
@@ -255,9 +278,7 @@ module ActionMCP
255
278
  end
256
279
 
257
280
  # Handle array values (take the last one as per TypeScript SDK)
258
- if header_version.is_a?(Array)
259
- header_version = header_version.last
260
- end
281
+ header_version = header_version.last if header_version.is_a?(Array)
261
282
 
262
283
  # Check if the header version is supported
263
284
  unless ActionMCP::SUPPORTED_VERSIONS.include?(header_version)
@@ -268,7 +289,7 @@ module ActionMCP
268
289
  end
269
290
 
270
291
  # If we have an initialized session, check if the header matches the negotiated version
271
- if session && session.initialized?
292
+ if session&.initialized?
272
293
  negotiated_version = session.protocol_version
273
294
  if header_version != negotiated_version
274
295
  ActionMCP.logger.warn "MCP-Protocol-Version mismatch: header=#{header_version}, negotiated=#{negotiated_version}"
@@ -340,9 +361,7 @@ module ActionMCP
340
361
  # Processes the results from the JsonRpcHandler.
341
362
  def process_handler_results(result, session, session_initially_missing, is_initialize_request)
342
363
  # Handle empty result (notifications)
343
- if result.nil?
344
- return head :accepted
345
- end
364
+ return head :accepted if result.nil?
346
365
 
347
366
  # Convert to hash for rendering
348
367
  payload = if result.respond_to?(:to_h)
@@ -369,9 +388,7 @@ module ActionMCP
369
388
  def render_json_response(payload, session, add_session_header)
370
389
  response.headers[MCP_SESSION_ID_HEADER] = session.id if add_session_header
371
390
  # Add MCP-Protocol-Version header if session has been initialized
372
- if session && session.initialized?
373
- response.headers["MCP-Protocol-Version"] = session.protocol_version
374
- end
391
+ response.headers["MCP-Protocol-Version"] = session.protocol_version if session&.initialized?
375
392
  response.headers["Content-Type"] = "application/json"
376
393
  render json: payload, status: :ok
377
394
  end
@@ -380,9 +397,7 @@ module ActionMCP
380
397
  def render_sse_response(payload, session, add_session_header)
381
398
  response.headers[MCP_SESSION_ID_HEADER] = session.id if add_session_header
382
399
  # Add MCP-Protocol-Version header if session has been initialized
383
- if session && session.initialized?
384
- response.headers["MCP-Protocol-Version"] = session.protocol_version
385
- end
400
+ response.headers["MCP-Protocol-Version"] = session.protocol_version if session&.initialized?
386
401
  response.headers["Content-Type"] = "text/event-stream"
387
402
  response.headers["X-Accel-Buffering"] = "no"
388
403
  response.headers["Cache-Control"] = "no-cache"
@@ -425,13 +440,13 @@ module ActionMCP
425
440
 
426
441
  # Helper to clean up old SSE events for a session
427
442
  def cleanup_old_sse_events(session)
428
- begin
429
- retention_period = session.sse_event_retention_period
430
- count = session.cleanup_old_sse_events(retention_period)
431
- Rails.logger.debug "Cleaned up #{count} old SSE events for session: #{session.id}" if count.positive? && ActionMCP.configuration.verbose_logging
432
- rescue StandardError => e
433
- Rails.logger.error "Error cleaning up old SSE events: #{e.message}"
443
+ retention_period = session.sse_event_retention_period
444
+ count = session.cleanup_old_sse_events(retention_period)
445
+ if count.positive? && ActionMCP.configuration.verbose_logging
446
+ Rails.logger.debug "Cleaned up #{count} old SSE events for session: #{session.id}"
434
447
  end
448
+ rescue StandardError => e
449
+ Rails.logger.error "Error cleaning up old SSE events: #{e.message}"
435
450
  end
436
451
 
437
452
  def format_tools_list(tools, session)
@@ -505,30 +520,30 @@ module ActionMCP
505
520
  # Authenticates the request using the configured gateway
506
521
  def authenticate_gateway!
507
522
  # Skip authentication for initialization-related requests in POST method
508
- if request.post? && defined?(jsonrpc_params) && jsonrpc_params
509
- return if initialization_related_request?(jsonrpc_params)
523
+ if request.post? && defined?(jsonrpc_params) && jsonrpc_params && initialization_related_request?(jsonrpc_params)
524
+ return
510
525
  end
511
526
 
512
527
  gateway_class = ActionMCP.configuration.gateway_class
513
528
  return unless gateway_class # Skip if no gateway configured
514
529
 
515
- gateway = gateway_class.new(request)
516
- gateway.call
517
- rescue ActionMCP::UnauthorizedError => e
518
- render_unauthorized(e.message)
530
+ begin
531
+ gateway = gateway_class.new(request)
532
+ gateway.call
533
+ rescue ActionMCP::UnauthorizedError => e
534
+ render_unauthorized(e.message)
535
+ rescue StandardError => e
536
+ Rails.logger.error "Gateway authentication error: #{e.class} - #{e.message}"
537
+ render_unauthorized("Authentication system error")
538
+ end
519
539
  end
520
540
 
521
541
  # Renders an unauthorized response
522
542
  def render_unauthorized(message = "Unauthorized", id = nil)
523
543
  id ||= extract_jsonrpc_id_from_request
524
544
 
525
- # Add WWW-Authenticate header for OAuth discovery as per spec
526
- auth_methods = ActionMCP.configuration.authentication_methods || []
527
- if auth_methods.include?("oauth")
528
- response.headers["WWW-Authenticate"] = 'Bearer realm="MCP API"'
529
- end
530
-
531
- render json: { jsonrpc: "2.0", id: id, error: { code: -32_000, message: message } }, status: :unauthorized
545
+ # Return JSON-RPC error with 200 status as per MCP specification
546
+ render json: { jsonrpc: "2.0", id: id, error: { code: -32_000, message: message } }
532
547
  end
533
548
  end
534
549
  end
@@ -1,29 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # == Schema Information
3
+ # <rails-lens:schema:begin>
4
+ # table = "action_mcp_session_messages"
5
+ # database_dialect = "SQLite"
4
6
  #
5
- # Table name: action_mcp_session_messages
7
+ # columns = [
8
+ # { name = "id", type = "integer", primary_key = true, nullable = false },
9
+ # { name = "session_id", type = "string", nullable = false },
10
+ # { name = "direction", type = "string", nullable = false, default = "client" },
11
+ # { name = "message_type", type = "string", nullable = false },
12
+ # { name = "jsonrpc_id", type = "string", nullable = true },
13
+ # { name = "message_json", type = "json", nullable = true },
14
+ # { name = "is_ping", type = "boolean", nullable = false, default = "0" },
15
+ # { name = "request_acknowledged", type = "boolean", nullable = false, default = "0" },
16
+ # { name = "request_cancelled", type = "boolean", nullable = false, default = "0" },
17
+ # { name = "created_at", type = "datetime", nullable = false },
18
+ # { name = "updated_at", type = "datetime", nullable = false }
19
+ # ]
6
20
  #
7
- # id :integer not null, primary key
8
- # direction :string default("client"), not null
9
- # is_ping :boolean default(FALSE), not null
10
- # message_json :json
11
- # message_type :string not null
12
- # request_acknowledged :boolean default(FALSE), not null
13
- # request_cancelled :boolean default(FALSE), not null
14
- # created_at :datetime not null
15
- # updated_at :datetime not null
16
- # jsonrpc_id :string
17
- # session_id :string not null
21
+ # indexes = [
22
+ # { name = "index_action_mcp_session_messages_on_session_id", columns = ["session_id"] }
23
+ # ]
18
24
  #
19
- # Indexes
20
- #
21
- # index_action_mcp_session_messages_on_session_id (session_id)
22
- #
23
- # Foreign Keys
24
- #
25
- # session_id (session_id => action_mcp_sessions.id) ON DELETE => cascade ON UPDATE => cascade
25
+ # foreign_keys = [
26
+ # { column = "session_id", references_table = "action_mcp_sessions", references_column = "id", on_delete = "cascade", on_update = "cascade" }
27
+ # ]
26
28
  #
29
+ # == Notes
30
+ # - Column 'message_json' should probably have NOT NULL constraint
31
+ # - String column 'session_id' has no length limit - consider adding one
32
+ # - String column 'direction' has no length limit - consider adding one
33
+ # - String column 'message_type' has no length limit - consider adding one
34
+ # - String column 'jsonrpc_id' has no length limit - consider adding one
35
+ # - Column 'message_type' is commonly used in queries - consider adding an index
36
+ # - Column 'is_ping' uses non-conventional prefix - consider removing 'is_' or 'has_'
37
+ # <rails-lens:schema:end>
27
38
  module ActionMCP
28
39
  class Session
29
40
  #
@@ -1,29 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # == Schema Information
3
+ # <rails-lens:schema:begin>
4
+ # table = "action_mcp_session_resources"
5
+ # database_dialect = "SQLite"
4
6
  #
5
- # Table name: action_mcp_session_resources
7
+ # columns = [
8
+ # { name = "id", type = "integer", primary_key = true, nullable = false },
9
+ # { name = "session_id", type = "string", nullable = false },
10
+ # { name = "uri", type = "string", nullable = false },
11
+ # { name = "name", type = "string", nullable = true },
12
+ # { name = "description", type = "text", nullable = true },
13
+ # { name = "mime_type", type = "string", nullable = false },
14
+ # { name = "created_by_tool", type = "boolean", nullable = true, default = "0" },
15
+ # { name = "last_accessed_at", type = "datetime", nullable = true },
16
+ # { name = "metadata", type = "json", nullable = true },
17
+ # { name = "created_at", type = "datetime", nullable = false },
18
+ # { name = "updated_at", type = "datetime", nullable = false }
19
+ # ]
6
20
  #
7
- # id :integer not null, primary key
8
- # created_by_tool :boolean default(FALSE)
9
- # description :text
10
- # last_accessed_at :datetime
11
- # metadata :json
12
- # mime_type :string not null
13
- # name :string
14
- # uri :string not null
15
- # created_at :datetime not null
16
- # updated_at :datetime not null
17
- # session_id :string not null
21
+ # indexes = [
22
+ # { name = "index_action_mcp_session_resources_on_session_id", columns = ["session_id"] }
23
+ # ]
18
24
  #
19
- # Indexes
20
- #
21
- # index_action_mcp_session_resources_on_session_id (session_id)
22
- #
23
- # Foreign Keys
24
- #
25
- # session_id (session_id => action_mcp_sessions.id) ON DELETE => cascade
25
+ # foreign_keys = [
26
+ # { column = "session_id", references_table = "action_mcp_sessions", references_column = "id", on_delete = "cascade" }
27
+ # ]
26
28
  #
29
+ # == Notes
30
+ # - Association 'session' should specify inverse_of
31
+ # - Column 'name' should probably have NOT NULL constraint
32
+ # - Column 'description' should probably have NOT NULL constraint
33
+ # - Column 'created_by_tool' should probably have NOT NULL constraint
34
+ # - Column 'metadata' should probably have NOT NULL constraint
35
+ # - String column 'session_id' has no length limit - consider adding one
36
+ # - String column 'uri' has no length limit - consider adding one
37
+ # - String column 'name' has no length limit - consider adding one
38
+ # - String column 'mime_type' has no length limit - consider adding one
39
+ # - Large text column 'description' is frequently queried - consider separate storage
40
+ # - Column 'mime_type' is commonly used in queries - consider adding an index
41
+ # <rails-lens:schema:end>
27
42
  module ActionMCP
28
43
  class Session
29
44
  #