active_scaffold_mcp 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: eff75b33a855b38a03f5b80a24ed92124bf4ffa6891a07cfac8683e2e9e7cb09
4
+ data.tar.gz: b8b8f6b7b6676f8fe7c6ef3ab7879135f7bfb500a675de35aa5e804636bd2049
5
+ SHA512:
6
+ metadata.gz: 31cfd450925701dc8ceffb2ad3135e154c8b28174189d56d1a50ac3ef369d5ece315d0d6a80e01b93189deecd6088e523d900cbb782ef0b4b713c413314a6e30
7
+ data.tar.gz: 750b76a58cc55a2ee206d762ab67749e7e827434e5bde50c07f1ddd590e7172aec6a803f6c249e30d39b346802fe4b5e888d1aad21617337ee6cfdb655b63792
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Servpac developers
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,505 @@
1
+ # ActiveScaffold MCP
2
+
3
+ `active_scaffold_mcp` generates ActionMCP tools from ActiveScaffold controller configuration.
4
+
5
+ The gem is under initial development. The first implementation will provide a configuration DSL, a generated-tool registry, and read-only list and show tools.
6
+
7
+ Association columns use ActiveScaffold's list settings in MCP results. An
8
+ unlimited collection (`associated_limit = nil`) is returned as an array. A
9
+ positive limit returns `{records: [...], count: N}` when the complete
10
+ association fits. A truncated association uses `first_records` instead of
11
+ `records`. The count is exact when `associated_number` is enabled; otherwise
12
+ MCP loads one extra record and reports a lower bound such as `count: ">3"`.
13
+ A zero limit is count-only (or `null` when `associated_number` is disabled).
14
+ Singular polymorphic associations also include their human model name as
15
+ `model`.
16
+
17
+ Configure the actions that may become MCP tools in the controller:
18
+
19
+ ```ruby
20
+ active_scaffold :ticket do |config|
21
+ config.mcp.actions = {
22
+ list: {
23
+ columns: %i[id subject status customer_id],
24
+ filter_columns: {
25
+ id: %i[equal in between],
26
+ status: %i[equal in]
27
+ },
28
+ filters: %i[open overdue]
29
+ },
30
+ show: {
31
+ columns: %i[subject status description],
32
+ lookup_columns: %i[case_number]
33
+ },
34
+ update: {columns: %i[status description]}
35
+ }
36
+ end
37
+ ```
38
+
39
+ The same configuration can be written with the per-action DSL:
40
+
41
+ ```ruby
42
+ active_scaffold :ticket do |config|
43
+ config.mcp.name = :support_tickets
44
+ config.mcp.label = "Support tickets"
45
+ config.mcp.expose :list,
46
+ description: "Find support tickets using queue filters and pagination.",
47
+ columns: %i[id subject status customer_id],
48
+ filter_columns: {status: %i[equal in]},
49
+ filters: %i[open overdue]
50
+ config.mcp.show.columns = %i[id subject status description]
51
+ config.mcp.show.lookup_columns = %i[case_number]
52
+ end
53
+ ```
54
+
55
+ `config.mcp.name` is the stable resource name shared by its generated tools.
56
+ When omitted, it uses `model.model_name.route_key`. Generators prefix it with
57
+ their action, producing names such as `list_support_tickets`; future action
58
+ generators can use the same value for names such as `show_support_ticket`.
59
+
60
+ `config.mcp.label` is the human-facing resource label shared by exposed
61
+ actions. Like ActiveScaffold's list label, it falls back to
62
+ `config.core.label(count: 2)`. In the example above the list title is
63
+ `List Support tickets`.
64
+
65
+ Both values can still be overridden for one action. An action-level `name` is
66
+ the complete tool identifier rather than a resource name:
67
+
68
+ ```ruby
69
+ config.mcp.expose :list,
70
+ name: :search_support_tickets,
71
+ label: "Searchable support tickets"
72
+ ```
73
+
74
+ ActionMCP's `title` DSL stores `annotations.title`: a human-readable display
75
+ name that MCP clients may show. It is separate from `description`, which tells
76
+ the model what the tool does. The generated list description also states which
77
+ filter categories are available and that `limit` and `page` control pagination.
78
+ Set `description` explicitly when business-specific guidance would be more
79
+ useful. `name` remains the protocol identifier.
80
+
81
+ When the ActiveScaffold controller enables `config_list`, generated list tools
82
+ also expose `list_view` when configured or saved named views are enabled. The
83
+ agent-facing name is translated to ActiveScaffold's `config_list_view`
84
+ parameter. A view selects its columns and default sorting. Configured names are
85
+ listed in the description and used as the enum when they are the complete set.
86
+ When saved views are enabled, the property remains free-form and its description
87
+ names a generated `<list tool name>_views` tool. That companion tool returns
88
+ authorized configured views and current-user saved views with the exact `value`
89
+ to pass as `list_view`. Explicit MCP
90
+ `columns:` configuration supplies the default output when `list_view` is
91
+ omitted; an explicitly selected view supplies the output columns for that call.
92
+
93
+ Named-view security methods execute in the generated tool, like other list and
94
+ filter hooks. Define them in a shared module and add that module to the MCP list
95
+ action's `modules`; the gem does not instantiate the source Rails controller.
96
+
97
+ Use `expose` to introduce and configure a custom action that does not yet have
98
+ an MCP configuration entry:
99
+
100
+ ```ruby
101
+ active_scaffold :customer do |config|
102
+ config.mcp.expose :sepa_mandate do |action|
103
+ action.columns = %i[account creditor_identifier]
104
+ action.description = "Create a SEPA mandate"
105
+ end
106
+ end
107
+ ```
108
+
109
+ Set `config.mcp.actions = :all` to expose all enabled ActiveScaffold actions.
110
+ No actions are exposed by default. The configured tool generator is called when
111
+ the MCP action module is included in the controller.
112
+
113
+ Common actions can be exposed globally:
114
+
115
+ ```ruby
116
+ ActiveScaffold.defaults do |config|
117
+ config.mcp.expose :list do |action|
118
+ action.filter_columns = {id: %i[equal in]}
119
+ end
120
+ config.mcp.expose :show
121
+ end
122
+ ```
123
+
124
+ Controller-specific custom actions should be exposed in that controller's
125
+ `active_scaffold` block.
126
+
127
+ Controller behavior that must also apply to an MCP tool should live in a shared
128
+ module. Include it in the controller and configure it for the action:
129
+
130
+ ```ruby
131
+ module TicketListBehavior
132
+ def list_authorized?
133
+ current_user.has_right?("tickets", "index")
134
+ end
135
+
136
+ def beginning_of_chain
137
+ super.where(account_id: current_user.account_id)
138
+ end
139
+ end
140
+
141
+ class TicketsController < ApplicationController
142
+ active_scaffold :ticket do |config|
143
+ # Included in the controller after all ActiveScaffold action modules.
144
+ config.custom_modules << TicketListBehavior
145
+
146
+ # Included in the generated list tool after Actions::List.
147
+ config.mcp.expose :list, modules: TicketListBehavior
148
+ end
149
+ end
150
+ ```
151
+
152
+ Configured modules are included after the ActiveScaffold action module, so they
153
+ can override hooks such as `list_authorized?`, `beginning_of_chain`,
154
+ `conditions_for_collection`, and named-filter security methods. Keep HTTP-only
155
+ methods out of shared modules.
156
+
157
+ Record serialization is handled by `ActiveScaffold::MCP::Presenter`, separately
158
+ from the ActiveScaffold action modules included by the generated tool. A custom
159
+ presenter class may replace it for an action:
160
+
161
+ ```ruby
162
+ class TicketMCPPresenter < ActiveScaffold::MCP::Presenter
163
+ # Override presentable_column?, record, or the protected presentation helpers.
164
+ end
165
+
166
+ config.mcp.expose :show, presenter: TicketMCPPresenter
167
+ ```
168
+
169
+ A replacement which does not subclass the default presenter must accept the
170
+ tool in `initialize`, implement `presentable_column?`, and implement
171
+ `record(record, columns, association_counts)`. The presenter exposes its tool
172
+ and delegates `active_scaffold_config`, `params`, and `current_user` to it for
173
+ presentation which depends on the current invocation. Subclassing the default
174
+ presenter is recommended when only part of the standard serialization behavior
175
+ needs to change.
176
+
177
+ Use `presenter_modules` for action-specific presentation behavior without
178
+ creating a presenter subclass:
179
+
180
+ ```ruby
181
+ module TicketMCPPresentation
182
+ def summary_column(record, _column)
183
+ record.summary.to_s.strip
184
+ end
185
+ end
186
+
187
+ config.mcp.expose :list, presenter_modules: TicketMCPPresentation
188
+ config.mcp.expose :show, presenter_modules: TicketMCPPresentation
189
+ ```
190
+
191
+ Each generated action gets its own anonymous presenter subclass when presenter
192
+ modules are configured, so the modules do not modify the base presenter or
193
+ other MCP tools. `modules` extends the tool's query and authorization behavior;
194
+ `presenter_modules` extends only record presentation.
195
+
196
+ Default filter-column sets can be configured globally:
197
+
198
+ ```ruby
199
+ ActiveScaffold.defaults do |config|
200
+ config.mcp.filter_column_sets = {
201
+ foreign_keys: :in,
202
+ timestamps: :range
203
+ }
204
+ end
205
+ ```
206
+
207
+ `filter_column_sets` requires a hash so every set has an explicit operator; the
208
+ gem does not assume whether a foreign key should use equality or membership, or
209
+ whether a timestamp should use equality or a range:
210
+
211
+ ```ruby
212
+ config.mcp.expose :list,
213
+ filters: :all,
214
+ filter_column_sets: {foreign_keys: :in, timestamps: :range}
215
+ ```
216
+
217
+ The generated base tool inherits from `ApplicationMCPTool` when that constant is
218
+ available, falling back to `ActionMCP::Tool`. It can be configured explicitly
219
+ before any generated tools are built:
220
+
221
+ ```ruby
222
+ ActiveScaffold.defaults do |config|
223
+ config.mcp.base_tool = "ApplicationMCPTool"
224
+ end
225
+ ```
226
+
227
+ An application base tool may define the class method
228
+ `active_scaffold_tool_authorized?` to filter generated tools for the current
229
+ user and apply the same application-level authorization before execution:
230
+
231
+ ```ruby
232
+ class ApplicationMCPTool < ActionMCP::Tool
233
+ def self.active_scaffold_tool_authorized?(controller_class:, action_name:, user:)
234
+ user.has_right?(controller_class.controller_path_for_rights, action_name)
235
+ end
236
+ end
237
+ ```
238
+
239
+ The hook is optional and should be a cheap check independent of tool arguments.
240
+ `ActionMCP::Configuration#filtered_tools` applies the active profile first and
241
+ then calls it using `ActionMCP::Current.user`. Tools without a class-level
242
+ authorization predicate are left unchanged. ActionMCP stores that filtered list
243
+ in the session, so a client must reconnect after its permissions change.
244
+
245
+ Before execution the generated tool checks the hook again inside
246
+ ActiveScaffold's current-user context. Action-specific instance checks are then
247
+ added by each tool type; for example, list tools also call `list_authorized?`.
248
+
249
+ When any execution-level authorization check returns `false`, the generated
250
+ tool calls the private instance method `unauthorized_tool!` instead of running
251
+ `perform`. By default it raises `ActiveScaffold::ActionNotAllowed`. The
252
+ configured application base tool may implement the method to use an
253
+ application-specific exception or message:
254
+
255
+ ```ruby
256
+ class ApplicationMCPTool < ActionMCP::Tool
257
+ private
258
+
259
+ def unauthorized_tool!
260
+ raise ActiveScaffold::ActionNotAllowed,
261
+ "You are not allowed to use #{self.class.tool_name}"
262
+ end
263
+ end
264
+ ```
265
+
266
+ The application implementation replaces the default fallback and should not
267
+ call `super`. If it returns normally, the callback still stops before
268
+ `perform`; ActionMCP will report the operation as aborted unless the hook has
269
+ already reported an error. This hook applies only during execution. Discovery
270
+ authorization is handled by the class-level `tool_authorized?` predicate.
271
+
272
+ ### Inspecting registered and authorized tools
273
+
274
+ `action_mcp:list_tools` reads the complete ActionMCP registry and therefore
275
+ lists generated tools without applying profiles or per-user authorization.
276
+ `action_mcp:show_profile[profile_name]` calls
277
+ `ActionMCP::Configuration#filtered_tools`. If
278
+ `active_scaffold_tool_authorized?` requires a user, the generated tools are not
279
+ shown because a Rake task does not establish `ActionMCP::Current.user`.
280
+
281
+ ActionMCP's `show_profile` task accepts only `profile_name`. The gem adds a
282
+ prerequisite that can resolve the current user from `MCP_USER=CLASS#ID` before
283
+ the original task runs. The class may be namespaced and must respond to `find`;
284
+ the gem does not assume a particular user class or persistence library:
285
+
286
+ ```sh
287
+ READONLY=1 RAILS_ENV=production MCP_USER='User#123' \
288
+ bin/rails 'action_mcp:show_profile[primary]'
289
+ ```
290
+
291
+ For a namespaced class, use a value such as
292
+ `MCP_USER='Accounts::User#123'`. Without `MCP_USER`, the task leaves
293
+ `ActionMCP::Current.user` unset and retains ActionMCP's normal behavior.
294
+
295
+ The full accepted arguments are in each tool's `inputSchema`. ActionMCP can
296
+ print all registered schemas as JSON:
297
+
298
+ ```sh
299
+ READONLY=1 RAILS_ENV=production bin/rails action_mcp:apps:schema |
300
+ jq '.tools[] | select(.name == "list_support_tickets")'
301
+ ```
302
+
303
+ Known ActiveScaffold actions validate their supported settings. Settings for an
304
+ unknown custom action are accepted unless the configured validator implements a
305
+ matching validation method:
306
+
307
+ ```ruby
308
+ class ApplicationMCPSettingsValidator < ActiveScaffold::MCP::SettingsValidator
309
+ private
310
+
311
+ def valid_settings_for_publish?(setting, value)
312
+ setting == :notify && [true, false].include?(value)
313
+ end
314
+ end
315
+
316
+ ActiveScaffold.defaults do |config|
317
+ config.mcp.settings_validator = ApplicationMCPSettingsValidator
318
+ end
319
+ ```
320
+
321
+ ## Tool generators
322
+
323
+ `config.mcp.tool_generator` defaults to an
324
+ `ActiveScaffold::MCP::GeneratorFactory`. Generators may be registered for exact
325
+ actions or selected from action metadata:
326
+
327
+ ```ruby
328
+ ActiveScaffold.defaults do |config|
329
+ factory = config.mcp.tool_generator
330
+
331
+ factory.register(ListToolGenerator, actions: :list)
332
+ factory.register(ShowToolGenerator, actions: %i[show details])
333
+
334
+ factory.register_default(
335
+ FormToolGenerator,
336
+ crud_type: :create,
337
+ columns: true
338
+ )
339
+
340
+ factory.register_custom(
341
+ DestructiveActionToolGenerator,
342
+ submit_method: :post,
343
+ columns: false,
344
+ destructive: true
345
+ )
346
+ end
347
+ ```
348
+
349
+ An action-specific generator implements:
350
+
351
+ ```ruby
352
+ def self.generate_tool_for(
353
+ controller_class:,
354
+ action_name:,
355
+ action_config:,
356
+ mcp_config:,
357
+ settings:
358
+ )
359
+ # Return a generated tool class, an array of tool classes, or nil.
360
+ end
361
+ ```
362
+
363
+ The factory uses `generate_tools_for`.
364
+
365
+ The built-in `ListToolGenerator` is registered explicitly for `:list`, and
366
+ `ShowToolGenerator` is registered for `:show`. The list generator
367
+ generates a read-only subclass of `ActiveScaffold::MCP::ListTool`. The base list
368
+ tool reuses ActiveScaffold's `do_list` and owns execution, pagination,
369
+ authorization, and result serialization. The generator builds the concrete
370
+ tool metadata, input schema, parameter mappings, and configured extensions.
371
+ Generated list tools support:
372
+
373
+ - Explicit `filter_columns`, with `equal`, `in`, `between`, `null` and their
374
+ negated variants.
375
+ - The `foreign_keys`, `dates`, and `timestamps` filter-column sets.
376
+ - Selected named list filters or `filters: :all`. Named filters keep their
377
+ ActiveScaffold parameter name (for example, `filter_status`) in the tool schema.
378
+ - A `search` argument whenever ActiveScaffold's search action is enabled. It
379
+ uses the configured `config.search.columns` and can be combined with the other
380
+ list filters. Set `config.mcp.list.search = false` to omit it for MCP.
381
+ - Configurable output columns plus bounded `limit` and `page` arguments.
382
+
383
+ When a selected foreign key belongs to a polymorphic association, its type
384
+ column is exposed with the same operators. For example, selecting `company_id`
385
+ also exposes `company_type` (or `company_id_in` and `company_type_in` when using
386
+ the `in` operator).
387
+
388
+ The model primary key is always included in list and show output, even when it
389
+ is omitted from the configured MCP output columns, subject to the same column
390
+ and record authorization as every other value. Output columns selected from the
391
+ normal list configuration already pass through
392
+ ActiveScaffold's class-level visibility checks. Explicit MCP output columns are
393
+ checked with the model's `authorized_for?`, and every output value is also
394
+ checked with the individual record's `authorized_for?` before serialization.
395
+ Association columns have a stable representation: a singular association is
396
+ `{"id": ..., "label": ...}` or `null`, and a collection association is an
397
+ array of those objects. Labels use the associated record's `to_label`.
398
+
399
+ ### Virtual output columns
400
+
401
+ When MCP `columns` are not configured explicitly, list and show tools reuse the
402
+ visible columns from `config.list.columns` and `config.show.columns`,
403
+ respectively. Those ActiveScaffold action columns may include virtual columns
404
+ which exist only for the HTML view and are rendered by helpers such as
405
+ `status_column(record, column)`.
406
+
407
+ MCP does not call ActiveScaffold column helpers. They are view-layer methods
408
+ which may return HTML, links, images, or other browser-specific output. During
409
+ list and show serialization, a non-association column is included only when the
410
+ record responds to the column name and that method can be called without
411
+ arguments. Consequently, a helper-only virtual column is omitted from the MCP
412
+ record instead of being returned as `null`:
413
+
414
+ ```ruby
415
+ active_scaffold :device do |config|
416
+ config.list.columns = %i[id name status_badge]
417
+ config.show.columns = %i[id name status_badge]
418
+ end
419
+
420
+ module DevicesHelper
421
+ def status_badge_column(record, column)
422
+ content_tag(:span, record.status, class: "badge")
423
+ end
424
+ end
425
+ ```
426
+
427
+ In this example, generated list and show results contain `id` and `name`, but
428
+ not `status_badge`. The same rule applies when the virtual column is selected
429
+ explicitly with `config.mcp.list.columns`, `config.mcp.show.columns`, or the
430
+ `columns:` option passed to `config.mcp.expose`.
431
+
432
+ A virtual column backed by a public model instance method which accepts no
433
+ arguments is included, using the method's raw return value rather than a column
434
+ helper. Methods with only optional arguments can also be called without
435
+ arguments and are included. Model-backed columns whose value is `nil` are still
436
+ included as JSON `null`; omission is reserved for columns which are neither
437
+ associations nor readable from the record without arguments.
438
+
439
+ If an MCP tool needs a value which requires arguments or currently exists only
440
+ as HTML presentation logic, add a presenter module to the MCP action and define
441
+ `<column>_column(record, column)`, following the same naming convention as an
442
+ ActiveScaffold column helper. The method should return presentation-independent
443
+ data rather than HTML. Select the column for MCP output as usual:
444
+
445
+ ```ruby
446
+ config.mcp.expose :show,
447
+ columns: %i[id status_badge],
448
+ presenter_modules: DeviceMCPPresentation
449
+ ```
450
+
451
+ The result includes the records and ActiveScaffold's pagination metadata. The
452
+ total reuses the count already calculated by ActiveScaffold rather than issuing
453
+ another count query:
454
+
455
+ ```json
456
+ {
457
+ "records": [],
458
+ "page": 2,
459
+ "total_count": 57,
460
+ "total_pages": 3
461
+ }
462
+ ```
463
+
464
+ The show generator creates a read-only `show_<singular-resource>` tool. By default it
465
+ requires `id`, whose JSON type follows the model primary key. Configure
466
+ `lookup_columns` to expose alternative identifiers such as `case_number`:
467
+
468
+ ```ruby
469
+ config.mcp.show.lookup_columns = %i[case_number]
470
+ ```
471
+
472
+ Callers must provide exactly one of `id` or the configured lookup columns. A
473
+ lookup must resolve to exactly one record; a missing or ambiguous value returns
474
+ an error. Lookup columns are explicit because application-level uniqueness is
475
+ often more meaningful than database-index introspection. The tool reuses
476
+ ActiveScaffold's filtered query, `do_show`, show authorization, configured show
477
+ columns, and the same record and association serialization as list. Its result
478
+ contains one record:
479
+
480
+ ```json
481
+ {
482
+ "record": {
483
+ "id": 7,
484
+ "name": "Widget 7"
485
+ }
486
+ }
487
+ ```
488
+
489
+ For example, an `in` comparator creates `<column>_in`, while `between` creates
490
+ `<column>_from` and `<column>_to`. These are translated back to the parameter
491
+ shape consumed by ActiveScaffold's `conditions_from_params`.
492
+
493
+ ## Development
494
+
495
+ The Servpac application loads the gem from `vendor/active_scaffold_mcp`:
496
+
497
+ ```ruby
498
+ gem "active_scaffold_mcp", path: "vendor/active_scaffold_mcp"
499
+ ```
500
+
501
+ Run its tests from the gem directory with:
502
+
503
+ ```sh
504
+ bundle exec rake test
505
+ ```
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveScaffold
4
+ module Actions
5
+ module MCP
6
+ def self.included(base)
7
+ ActiveScaffold::MCP.generate_tools_for(
8
+ base,
9
+ base.active_scaffold_config.mcp
10
+ )
11
+ end
12
+ end
13
+ end
14
+ end