mcp-rails 0.4.11
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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +403 -0
- data/Rakefile +3 -0
- data/app/controllers/concerns/mcp/rails/error_handling.rb +25 -0
- data/app/controllers/concerns/mcp/rails/parameters.rb +163 -0
- data/app/controllers/concerns/mcp/rails/renderer.rb +69 -0
- data/app/controllers/concerns/mcp/rails/tool_descriptions.rb +39 -0
- data/lib/mcp/rails/bypass_key_manager.rb +34 -0
- data/lib/mcp/rails/configuration.rb +86 -0
- data/lib/mcp/rails/railtie.rb +49 -0
- data/lib/mcp/rails/server_generator/route_collector.rb +84 -0
- data/lib/mcp/rails/server_generator/server_writer.rb +242 -0
- data/lib/mcp/rails/server_generator.rb +49 -0
- data/lib/mcp/rails/version.rb +5 -0
- data/lib/mcp/rails.rb +31 -0
- metadata +126 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: b6e405c23ef4fae7964591eb567ee9f383b436f3d602cd02b2f2d088ba5c256a
|
4
|
+
data.tar.gz: 55ee2b62e45a44c2b6f9a1a9b172414c8bfbb5c98db5e0b68132594df81b9f87
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 88491aab2b61efd838a5e32ee9da92eebef4c642fd294200a125071d853916d3e97dcf2d983ef373321ca9cee2a10ab010b1fa945fb8438a19185dcb13e4ef94
|
7
|
+
data.tar.gz: 03aa0d2516a2144794fc9b6c6328734a45f23debc92ef6b19ee926db2a13b942262dbc2cc9902a883dd8facc6dd9512d6fba7e9065f712568fa1903231aebe26
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright Tonksthebear
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,403 @@
|
|
1
|
+
# MCP-Rails
|
2
|
+
|
3
|
+
**Enhance Rails routing and parameter handling for LLM agents with MCP (Machine Control Protocol) integration.**
|
4
|
+
|
5
|
+
`mcp-rails` is a Ruby on Rails gem that builds on top of the [mcp-rb](https://github.com/funwarioisii/mcp-rb) library to seamlessly integrate MCP (Model Context Protocol) servers into your Rails application. It enhances Rails routes by allowing you to tag them with MCP-specific metadata and generates a valid Ruby MCP server (in `tmp/server.rb`) that LLM agents, such as Goose, can connect to. Additionally, it provides a powerful way to define and manage strong parameters in your controllers, which doubles as both MCP server configuration and Rails strong parameter enforcement.
|
6
|
+
|
7
|
+
This was inspired during the creation of [Gaggle](https://github.com/Tonksthebear/gaggle).
|
8
|
+
|
9
|
+
---
|
10
|
+
|
11
|
+
## Features
|
12
|
+
|
13
|
+
- **Tagged Routes**: Tag Rails routes with `mcp: true` or specific actions (e.g., `mcp: [:index, :create]`) to expose them to an MCP server.
|
14
|
+
- **Automatic MCP Server Generation**: Generates a Ruby MCP server in `tmp/server.rb` for LLM agents to interact with your application.
|
15
|
+
- **Parameter Definition**: Define permitted parameters in controllers with rich metadata (e.g., types, examples, required flags) that are used for both MCP server generation and Rails strong parameters.
|
16
|
+
- **HTTP Bridge for LLM Agents**: Generates a ruby based MCP server to interact with your application through HTTP requests, ensuring LLM agents follow the same paths as human users.
|
17
|
+
- **Environment Variable Integration**: Automatically includes specified environment variables in MCP tool calls.
|
18
|
+
|
19
|
+
---
|
20
|
+
|
21
|
+
## Installation
|
22
|
+
|
23
|
+
Add this line to your application's `Gemfile`:
|
24
|
+
|
25
|
+
```ruby
|
26
|
+
gem 'mcp-rails'
|
27
|
+
```
|
28
|
+
|
29
|
+
Then run:
|
30
|
+
|
31
|
+
```bash
|
32
|
+
bundle install
|
33
|
+
```
|
34
|
+
|
35
|
+
Ensure you also have the `mcp-rb` gem installed, as `mcp-rails` depends on it:
|
36
|
+
|
37
|
+
```ruby
|
38
|
+
gem 'mcp-rb'
|
39
|
+
```
|
40
|
+
|
41
|
+
---
|
42
|
+
|
43
|
+
## Configuration
|
44
|
+
|
45
|
+
MCP-Rails can be configured in an initializer file. Create `config/initializers/mcp_rails.rb`:
|
46
|
+
|
47
|
+
```ruby
|
48
|
+
MCP::Rails.configure do |config|
|
49
|
+
# Server Configuration
|
50
|
+
config.server_name = "my-app-server" # Default: 'mcp-server'
|
51
|
+
config.server_version = "2.0.0" # Default: '1.0.0'
|
52
|
+
|
53
|
+
# Output Configuration
|
54
|
+
config.output_directory = Rails.root.join("tmp/mcp") # Default: Rails.root.join('tmp', 'mcp')
|
55
|
+
config.bypass_key_path = Rails.root.join("tmp/mcp/bypass_key.txt") # Default: Rails.root.join('tmp', 'mcp', 'bypass_key.txt')
|
56
|
+
|
57
|
+
# Environment Variables
|
58
|
+
config.env_vars = ["API_KEY", "ORGANIZATION_ID"] # Default: ['API_KEY', 'ORGANIZATION_ID']
|
59
|
+
|
60
|
+
# Base URL Configuration
|
61
|
+
config.base_url = "http://localhost:3000" # Default: Uses action_mailer.default_url_options
|
62
|
+
end
|
63
|
+
```
|
64
|
+
|
65
|
+
If you are an engine developer, you can register your engine's configuration with MCP-Rails:
|
66
|
+
|
67
|
+
```ruby
|
68
|
+
MCP::Rails.configure do |config|
|
69
|
+
config.register_engine(YourEngine, env_vars: ["YOUR_ENGINE_KEY"])
|
70
|
+
end
|
71
|
+
```
|
72
|
+
|
73
|
+
### Environment Variables
|
74
|
+
|
75
|
+
The `env_vars` configuration option specifies which environment variables should be automatically included in every MCP tool call. For example, if you configure:
|
76
|
+
|
77
|
+
```ruby
|
78
|
+
config.env_vars = ["API_KEY", "ORGANIZATION_ID"]
|
79
|
+
```
|
80
|
+
|
81
|
+
Then every MCP tool call will automatically include these environment variables as parameters:
|
82
|
+
|
83
|
+
```ruby
|
84
|
+
# If ENV['API_KEY'] = 'xyz123' and ENV['ORGANIZATION_ID'] = '456'
|
85
|
+
# A tool call like:
|
86
|
+
create_channel(name: "General")
|
87
|
+
# Will effectively become:
|
88
|
+
create_channel(name: "General", api_key: "xyz123", organization_id: "456")
|
89
|
+
```
|
90
|
+
|
91
|
+
This is useful for automatically including authentication tokens, organization IDs, or other environment-specific values in your MCP tool calls without explicitly defining them in your controller parameters.
|
92
|
+
|
93
|
+
### Base URL
|
94
|
+
|
95
|
+
The base URL for API requests is determined in the following order:
|
96
|
+
1. Custom configuration via `config.base_url = "http://example.com"`
|
97
|
+
2. Rails `action_mailer.default_url_options` settings
|
98
|
+
3. Default fallback to `"http://localhost:3000"`
|
99
|
+
|
100
|
+
---
|
101
|
+
|
102
|
+
## Usage
|
103
|
+
|
104
|
+
### 1. Tagging Routes
|
105
|
+
|
106
|
+
In your `config/routes.rb`, tag routes that should be exposed to the MCP server:
|
107
|
+
|
108
|
+
```ruby
|
109
|
+
Rails.application.routes.draw do
|
110
|
+
resources :channels, mcp: true # Exposes all RESTful actions to MCP
|
111
|
+
# OR
|
112
|
+
resources :channels, mcp: [:index, :create] # Exposes only specified actions
|
113
|
+
end
|
114
|
+
```
|
115
|
+
|
116
|
+
### 2. Defining Parameters
|
117
|
+
|
118
|
+
In your controllers, use the `permitted_params_for` DSL to define parameters for MCP actions. These definitions serve a dual purpose: they configure the MCP server and enable strong parameter enforcement in Rails.
|
119
|
+
|
120
|
+
Example:
|
121
|
+
|
122
|
+
```ruby
|
123
|
+
class ChannelsController < ApplicationController
|
124
|
+
# Define parameters for the :create action
|
125
|
+
permitted_params_for :create do
|
126
|
+
param :channel, required: true do
|
127
|
+
param :name, type: :string, example: "Channel Name", required: true
|
128
|
+
param :user_ids, type: :array, item_type: :string, example: ["1", "2"]
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
def create
|
133
|
+
@channel = Channel.new(resource_params) # Automatically uses the defined params
|
134
|
+
if @channel.save
|
135
|
+
render json: @channel, status: :created
|
136
|
+
else
|
137
|
+
render json: @channel.errors, status: :unprocessable_entity
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
```
|
142
|
+
|
143
|
+
The LLM will now provide the exact parameters you're used to with default rails routes, inluding the nesting of resources. For example, the LLM will create a channel with the following params
|
144
|
+
```
|
145
|
+
{ channel: { name: "Channel Name", user_ids: ["1", "2"] } }
|
146
|
+
```
|
147
|
+
|
148
|
+
- **MCP Server**: The generated `tmp/server.rb` will include these parameters, making them available to LLM agents.
|
149
|
+
- **Rails Strong Parameters**: Calling `resource_params` in your controller action automatically permits and fetches the defined parameters.
|
150
|
+
|
151
|
+
### 3. MCP Response Format
|
152
|
+
|
153
|
+
MCP-Rails registers a custom MIME type `application/vnd.mcp+json` for MCP-specific responses. This enables:
|
154
|
+
- Automatic key camelization for MCP protocol compatibility
|
155
|
+
- Standardized response wrapping with status and data
|
156
|
+
- View template fallbacks
|
157
|
+
|
158
|
+
#### View Templates
|
159
|
+
|
160
|
+
You can create MCP-specific views using the `.mcp.jbuilder` extension:
|
161
|
+
|
162
|
+
```ruby
|
163
|
+
# app/views/channels/show.mcp.jbuilder
|
164
|
+
json.name @channel.name
|
165
|
+
json.user_ids @channel.user_ids
|
166
|
+
```
|
167
|
+
|
168
|
+
If an MCP view doesn't exist, it will automatically fall back to the corresponding `.json.jbuilder` view:
|
169
|
+
|
170
|
+
```ruby
|
171
|
+
# app/views/channels/show.json.jbuilder
|
172
|
+
json.name @channel.name
|
173
|
+
json.user_ids @channel.user_ids
|
174
|
+
```
|
175
|
+
|
176
|
+
#### Explicit Rendering
|
177
|
+
|
178
|
+
You can also render MCP responses directly in your controllers:
|
179
|
+
|
180
|
+
```ruby
|
181
|
+
class ChannelsController < ApplicationController
|
182
|
+
def show
|
183
|
+
@channel = Channel.find(params[:id])
|
184
|
+
|
185
|
+
respond_to do |format|
|
186
|
+
format.json { render json: @channel }
|
187
|
+
format.mcp { render mcp: @channel } # Keys will be automatically camelized
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
```
|
192
|
+
|
193
|
+
#### Response Format
|
194
|
+
|
195
|
+
All MCP responses are automatically wrapped in a standardized format:
|
196
|
+
|
197
|
+
```json
|
198
|
+
{
|
199
|
+
"status": 200,
|
200
|
+
"data": {
|
201
|
+
"name": "General",
|
202
|
+
"userIds": ["1", "2"] # Note: automatically camelized
|
203
|
+
}
|
204
|
+
}
|
205
|
+
```
|
206
|
+
|
207
|
+
This format ensures compatibility with the generated MCP server
|
208
|
+
|
209
|
+
### 4. Customizing Tool Descriptions
|
210
|
+
|
211
|
+
By default, MCP-Rails generates tool descriptions in the format "Handles [action] for [controller]". You can customize these descriptions to be more specific and informative using the `tool_description_for` method in your controllers:
|
212
|
+
|
213
|
+
```ruby
|
214
|
+
class ChannelsController < ApplicationController
|
215
|
+
tool_description_for :create, "Create a new channel with the specified name and members"
|
216
|
+
tool_description_for :index, "List all available channels"
|
217
|
+
tool_description_for :show, "Get detailed information about a specific channel"
|
218
|
+
|
219
|
+
# ... rest of controller code
|
220
|
+
end
|
221
|
+
```
|
222
|
+
|
223
|
+
These descriptions will be used when generating the MCP server, making it clearer to LLM agents what each endpoint does.
|
224
|
+
|
225
|
+
### 5. Running the MCP Server
|
226
|
+
|
227
|
+
After tagging routes and defining parameters, run
|
228
|
+
|
229
|
+
```bash
|
230
|
+
bin/rails mcp:rails:generate_server
|
231
|
+
```
|
232
|
+
The MCP server will be generated in `tmp/server.rb`. The server.rb is an executable that attempts to find the closest Gemfile to the file and executes the server using that Gemfile.
|
233
|
+
|
234
|
+
If any engines are registered, the server will be generated for each engine as well.
|
235
|
+
|
236
|
+
LLM agents can now connect to this server and interact with your application via HTTP requests.
|
237
|
+
|
238
|
+
For an agent like Goose, you can use this new server with
|
239
|
+
```
|
240
|
+
goose session --with-extension "path_to/tmp/server.rb"
|
241
|
+
```
|
242
|
+
---
|
243
|
+
|
244
|
+
## Testing Your MCP Server
|
245
|
+
|
246
|
+
MCP-Rails provides a test helper module that makes it easy to integration test your MCP server responses. The helper automatically handles server generation, initialization, and cleanup while providing convenient methods to simulate MCP tool calls.
|
247
|
+
|
248
|
+
### Setup
|
249
|
+
|
250
|
+
Include the test helper in your test class:
|
251
|
+
|
252
|
+
```ruby
|
253
|
+
require "mcp/rails/test_helper"
|
254
|
+
|
255
|
+
class MCPChannelTest < ActionDispatch::IntegrationTest
|
256
|
+
include MCP::Rails::TestHelper
|
257
|
+
end
|
258
|
+
```
|
259
|
+
|
260
|
+
The helper automatically:
|
261
|
+
- Creates a temporary directory for server files
|
262
|
+
- Configures MCP-Rails to use this directory
|
263
|
+
- Generates the MCP server files
|
264
|
+
- Cleans up after each test
|
265
|
+
|
266
|
+
### Available Methods
|
267
|
+
|
268
|
+
- `mcp_servers`: Returns all generated MCP servers (main app and engines)
|
269
|
+
- `mcp_server(name: "mcp-server")`: Returns a specific server by name
|
270
|
+
- `mcp_response_body`: Returns the body of the last MCP response
|
271
|
+
- `mcp_tool_list(server)`: Gets the list of available tools from a server
|
272
|
+
- `mcp_tool_call(server, name, arguments = {})`: Makes a tool call to the server
|
273
|
+
|
274
|
+
### Example Usage
|
275
|
+
|
276
|
+
```ruby
|
277
|
+
class MCPChannelTest < ActionDispatch::IntegrationTest
|
278
|
+
include MCP::Rails::TestHelper
|
279
|
+
|
280
|
+
test "creates a channel via MCP" do
|
281
|
+
server = mcp_server
|
282
|
+
|
283
|
+
mcp_tool_call(
|
284
|
+
server,
|
285
|
+
"create_channel",
|
286
|
+
channel: { name: "General", user_ids: ["1", "2"] }
|
287
|
+
)
|
288
|
+
|
289
|
+
assert_equal false, mcp_response_body.dig(:result, :isError)
|
290
|
+
assert_equal "Channel created successfully", mcp_response_body.dig(:result, :message)
|
291
|
+
end
|
292
|
+
end
|
293
|
+
```
|
294
|
+
|
295
|
+
This approach allows you to verify that your MCP server correctly handles requests and integrates properly with your Rails application.
|
296
|
+
|
297
|
+
---
|
298
|
+
|
299
|
+
## How It Works
|
300
|
+
|
301
|
+
1. **Route Tagging**: The `mcp` option in your routes tells `mcp-rails` which endpoints to expose to the MCP server.
|
302
|
+
2. **Parameter Definition**: The `permitted_params_for` block defines the structure and metadata of parameters, which are used to generate the MCP server's API and enforce strong parameters in Rails.
|
303
|
+
3. **Server Generation**: `mcp-rails` leverages `mcp-rb` to create a Ruby MCP server in `tmp/server.rb`, translating tagged routes and parameters into an interface for LLM agents.
|
304
|
+
4. **HTTP Integration**: The generated server converts MCP tool calls into HTTP requests, allowing you to reuse all of the same logic for interacting with your application.
|
305
|
+
|
306
|
+
---
|
307
|
+
|
308
|
+
## Bypassing CSRF Protection
|
309
|
+
|
310
|
+
The MCP server generates new HTTP requests on the fly. In standard Rails applications, this is protected by a CSRF (Cross-Site Request Forgery) key that is provided to the client during normal interactions. Since we can't leverage this, `mcp-rails` will generate a unique key to bypass this protection. This is a rudementary way to provide protection and should not be depended upon in production. As such, the gem will not automatically skip this protection on your behalf. You will have to add the following to your `ApplicationController`:
|
311
|
+
|
312
|
+
```ruby
|
313
|
+
# app/controllers/application_controller.rb
|
314
|
+
class ApplicationController < ActionController::Base
|
315
|
+
skip_before_action :verify_authenticity_token, if: :mcp_invocation?
|
316
|
+
end
|
317
|
+
```
|
318
|
+
|
319
|
+
The server adds a `X-Bypass-CSRF` header to all requests. This token gets regenerated and re-applied every time the server is generated. The key is stored in `/tmp/mcp/bypass_key.txt`
|
320
|
+
|
321
|
+
## Example
|
322
|
+
|
323
|
+
### Routes
|
324
|
+
|
325
|
+
```ruby
|
326
|
+
# config/routes.rb
|
327
|
+
Rails.application.routes.draw do
|
328
|
+
resources :channels, only: [:index, :create], mcp: true
|
329
|
+
|
330
|
+
resources :posts, mcp: [:create]
|
331
|
+
end
|
332
|
+
```
|
333
|
+
|
334
|
+
### Controller
|
335
|
+
|
336
|
+
```ruby
|
337
|
+
# app/controllers/channels_controller.rb
|
338
|
+
class ChannelsController < ApplicationController
|
339
|
+
permitted_params_for :create do
|
340
|
+
param :channel, required: true do
|
341
|
+
param :name, type: :string, example: "General Chat", required: true
|
342
|
+
param :goose_ids, type: :array, example: ["goose-123", "goose-456"]
|
343
|
+
end
|
344
|
+
end
|
345
|
+
|
346
|
+
def index
|
347
|
+
@channels = Channel.all
|
348
|
+
render json: @channels
|
349
|
+
end
|
350
|
+
|
351
|
+
def create
|
352
|
+
@channel = Channel.new(resource_params)
|
353
|
+
if @channel.save
|
354
|
+
render json: @channel, status: :created
|
355
|
+
else
|
356
|
+
render json: @channel.errors, status: :unprocessable_entity
|
357
|
+
end
|
358
|
+
end
|
359
|
+
end
|
360
|
+
```
|
361
|
+
|
362
|
+
### Generated MCP Server
|
363
|
+
|
364
|
+
The `tmp/server.rb` file will include an MCP server that exposes `/channels` (GET) and `/channels` (POST) with the defined parameters, allowing an LLM agent to interact with your app.
|
365
|
+
|
366
|
+
For use with something like [Goose](https://github.com/block/goose):
|
367
|
+
```
|
368
|
+
goose session --with-extension "ruby path_to/tmp/server.rb"
|
369
|
+
```
|
370
|
+
---
|
371
|
+
|
372
|
+
## Requirements
|
373
|
+
|
374
|
+
- Ruby 3.0 or higher
|
375
|
+
- Rails 7.0 or higher
|
376
|
+
- `mcp-rb` gem
|
377
|
+
|
378
|
+
---
|
379
|
+
|
380
|
+
## Contributing
|
381
|
+
|
382
|
+
Bug reports and pull requests are welcome! Please submit them to the [GitHub repository](https://github.com/yourusername/mcp-rails).
|
383
|
+
|
384
|
+
1. Fork the repository.
|
385
|
+
2. Create a feature branch (`git checkout -b my-new-feature`).
|
386
|
+
3. Commit your changes (`git commit -am 'Add some feature'`).
|
387
|
+
4. Push to the branch (`git push origin my-new-feature`).
|
388
|
+
5. Create a new pull request.
|
389
|
+
|
390
|
+
---
|
391
|
+
|
392
|
+
## License
|
393
|
+
|
394
|
+
This gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
395
|
+
|
396
|
+
---
|
397
|
+
|
398
|
+
## Acknowledgments
|
399
|
+
|
400
|
+
- Built on top of the excellent `mcp-rb` library.
|
401
|
+
- Designed with LLM agents like Goose in mind.
|
402
|
+
|
403
|
+
---
|
data/Rakefile
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
module MCP::Rails::ErrorHandling
|
2
|
+
extend ActiveSupport::Concern
|
3
|
+
|
4
|
+
included do
|
5
|
+
rescue_from StandardError, with: :handle_mcp_error
|
6
|
+
end
|
7
|
+
|
8
|
+
private
|
9
|
+
|
10
|
+
def handle_mcp_error(exception)
|
11
|
+
if mcp_request?
|
12
|
+
render json: {
|
13
|
+
status: "error",
|
14
|
+
message: exception.message,
|
15
|
+
code: exception.class.name.underscore
|
16
|
+
}, status: :unprocessable_entity
|
17
|
+
else
|
18
|
+
raise exception # Re-raise the exception for non-mcp requests
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def mcp_request?
|
23
|
+
request.format == :mcp || request.accept.include?("application/vnd.mcp+json")
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,163 @@
|
|
1
|
+
module MCP::Rails::Parameters
|
2
|
+
extend ActiveSupport::Concern
|
3
|
+
|
4
|
+
def self.included(base)
|
5
|
+
base.extend ClassMethods
|
6
|
+
base.class_eval do
|
7
|
+
# Initialize instance variables for each class
|
8
|
+
@shared_params_defs = {}
|
9
|
+
@action_params_defs = {}
|
10
|
+
|
11
|
+
# Define class-level accessors
|
12
|
+
def self.shared_params_defs
|
13
|
+
@shared_params_defs ||= {}
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.action_params_defs
|
17
|
+
@action_params_defs ||= {}
|
18
|
+
end
|
19
|
+
|
20
|
+
# Optionally, define setters if needed
|
21
|
+
def self.shared_params_defs=(value)
|
22
|
+
@shared_params_defs = value
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.action_params_defs=(value)
|
26
|
+
@action_params_defs = value
|
27
|
+
end
|
28
|
+
|
29
|
+
# Ensure subclasses get their own fresh instances
|
30
|
+
def self.inherited(subclass)
|
31
|
+
super
|
32
|
+
subclass.instance_variable_set(:@shared_params_defs, {})
|
33
|
+
subclass.instance_variable_set(:@action_params_defs, {})
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def mcp_invocation?
|
38
|
+
bypass_key = request.headers["X-Bypass-CSRF"]
|
39
|
+
stored_key = File.read(MCP::Rails.configuration.bypass_key_path).strip rescue nil
|
40
|
+
bypass_key.present? && bypass_key == stored_key
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
class_methods do
|
45
|
+
# Define shared parameters
|
46
|
+
def shared_params(name, &block)
|
47
|
+
builder = ParamsBuilder.new
|
48
|
+
builder.instance_eval(&block)
|
49
|
+
shared_params_defs[name] = builder.params
|
50
|
+
end
|
51
|
+
|
52
|
+
# Define parameters for an action
|
53
|
+
def permitted_params_for(action, shared: [], &block)
|
54
|
+
# Gather shared params
|
55
|
+
shared_params = shared.map { |name| shared_params_defs[name] }.flatten.compact
|
56
|
+
# Build action-specific params
|
57
|
+
action_builder = ParamsBuilder.new
|
58
|
+
action_builder.instance_eval(&block) if block_given?
|
59
|
+
action_params = action_builder.params
|
60
|
+
# Merge, with action-specific overriding shared
|
61
|
+
merged_params = merge_params(shared_params, action_params)
|
62
|
+
action_params_defs[action.to_sym] = merged_params
|
63
|
+
end
|
64
|
+
|
65
|
+
# Get permitted parameters for an action
|
66
|
+
def permitted_params(action)
|
67
|
+
action_params_defs[action.to_sym] || []
|
68
|
+
end
|
69
|
+
|
70
|
+
def mcp_hash(action)
|
71
|
+
extract_permitted_mcp_hash(permitted_params(action))
|
72
|
+
end
|
73
|
+
|
74
|
+
def extract_permitted_mcp_hash(params_def)
|
75
|
+
param_hash = {}
|
76
|
+
params_def.each do |param|
|
77
|
+
param_hash[param[:name]] = {
|
78
|
+
type: param[:type],
|
79
|
+
required: param[:required]
|
80
|
+
}
|
81
|
+
if param[:nested]
|
82
|
+
param_hash[param[:name]][:type] = "object"
|
83
|
+
param_hash[param[:name]][:properties] = extract_permitted_mcp_hash(param[:nested])
|
84
|
+
end
|
85
|
+
end
|
86
|
+
param_hash
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
# Merge shared and action-specific params, with action-specific taking precedence
|
92
|
+
def merge_params(shared, specific)
|
93
|
+
specific_keys = specific.map { |p| p[:name] }
|
94
|
+
shared.reject { |p| specific_keys.include?(p[:name]) } + specific
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# Instance method to get strong parameters, keeping controller clean
|
99
|
+
def resource_params
|
100
|
+
permitted = extract_permitted_keys(self.class.permitted_params(action_name))
|
101
|
+
if (model_hash = permitted.select { |p| p.is_a?(Hash) }) && model_hash.length == 1
|
102
|
+
params.require(model_hash.first.keys.first).permit(*model_hash.first.values)
|
103
|
+
else
|
104
|
+
params.permit(permitted)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
private
|
109
|
+
|
110
|
+
|
111
|
+
# Helper to extract permitted keys for strong parameters
|
112
|
+
def extract_permitted_keys(params_def)
|
113
|
+
params_def.map do |param|
|
114
|
+
if param[:type] == :array
|
115
|
+
if param[:item_type]
|
116
|
+
{ param[:name] => [] } # Scalar array
|
117
|
+
elsif param[:nested]
|
118
|
+
{ param[:name] => extract_permitted_keys(param[:nested]) } # Array of hashes
|
119
|
+
else
|
120
|
+
raise "Invalid array parameter definition"
|
121
|
+
end
|
122
|
+
elsif param[:nested]
|
123
|
+
{ param[:name] => extract_permitted_keys(param[:nested]) } # Nested object
|
124
|
+
else
|
125
|
+
param[:name] # Scalar
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
# Builder class for parameter definitions
|
131
|
+
class ParamsBuilder
|
132
|
+
attr_reader :params
|
133
|
+
|
134
|
+
def initialize
|
135
|
+
@params = []
|
136
|
+
end
|
137
|
+
|
138
|
+
def param(name, type: nil, item_type: nil, example: nil, required: false, &block)
|
139
|
+
param_def = { name: name, required: required }
|
140
|
+
if type == :array
|
141
|
+
param_def[:type] = :array
|
142
|
+
if block_given?
|
143
|
+
nested_builder = ParamsBuilder.new
|
144
|
+
nested_builder.instance_eval(&block)
|
145
|
+
param_def[:nested] = nested_builder.params
|
146
|
+
elsif item_type
|
147
|
+
param_def[:item_type] = item_type
|
148
|
+
else
|
149
|
+
raise ArgumentError, "Must provide item_type or a block for array type"
|
150
|
+
end
|
151
|
+
elsif block_given?
|
152
|
+
param_def[:type] = :object
|
153
|
+
nested_builder = ParamsBuilder.new
|
154
|
+
nested_builder.instance_eval(&block)
|
155
|
+
param_def[:nested] = nested_builder.params
|
156
|
+
else
|
157
|
+
param_def[:type] = type if type
|
158
|
+
end
|
159
|
+
param_def[:example] = example if example
|
160
|
+
@params << param_def
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# lib/mcp/rails/renderer.rb
|
2
|
+
module MCP
|
3
|
+
module Rails
|
4
|
+
module Renderer
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
included do
|
8
|
+
alias_method :original_render, :render
|
9
|
+
alias_method :render, :mcp_render
|
10
|
+
end
|
11
|
+
|
12
|
+
def mcp_render(*args)
|
13
|
+
return original_render(*args) unless request.format.mcp?
|
14
|
+
|
15
|
+
options = args.extract_options!
|
16
|
+
if implicit_jbuilder_render?(options)
|
17
|
+
process_implicit_jbuilder_render(options)
|
18
|
+
elsif options[:json] || options[:mcp]
|
19
|
+
process_explicit_render(options)
|
20
|
+
end
|
21
|
+
original_render(*args, options)
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def implicit_jbuilder_render?(options)
|
27
|
+
options.empty? &&
|
28
|
+
lookup_context.exists?(action_name, lookup_context.prefixes, false, [], formats: [ :mcp, :json ], handlers: [ :jbuilder ])
|
29
|
+
end
|
30
|
+
|
31
|
+
def process_implicit_jbuilder_render(options)
|
32
|
+
template = lookup_context.find(action_name, lookup_context.prefixes, false, [], formats: [ :mcp, :json ], handlers: [ :jbuilder ])
|
33
|
+
data = JbuilderTemplate.new(view_context, key_format: :camelize) do |json|
|
34
|
+
json.key_format! camelize: :lower
|
35
|
+
view_context.instance_exec(json) do |j|
|
36
|
+
eval(template.source, binding, template.identifier)
|
37
|
+
end
|
38
|
+
end.attributes!
|
39
|
+
# Wrap in status/data structure
|
40
|
+
options[:json] = {
|
41
|
+
status: Rack::Utils.status_code(response.status || :ok),
|
42
|
+
data: data
|
43
|
+
}
|
44
|
+
end
|
45
|
+
|
46
|
+
def process_explicit_render(options)
|
47
|
+
status_code = Rack::Utils.status_code(options[:status] || :ok)
|
48
|
+
data = options.delete(:mcp) || options.delete(:json)
|
49
|
+
|
50
|
+
# Wrap in status/data structure
|
51
|
+
options[:json] = {
|
52
|
+
status: status_code,
|
53
|
+
data: format_keys(data)
|
54
|
+
}
|
55
|
+
end
|
56
|
+
|
57
|
+
def format_keys(data)
|
58
|
+
case data
|
59
|
+
when Hash
|
60
|
+
data.deep_transform_keys { |k| k.to_s.camelize(:lower) }
|
61
|
+
when Array
|
62
|
+
data.map { |item| format_keys(item) }
|
63
|
+
else
|
64
|
+
data
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|