activeadmin_mcp 0.0.4 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5eb6b95afa3bf6d96a2ae33ced52621014f535f11542dcbee38b88638580e6a9
4
- data.tar.gz: c87121826ee5e29805cfa9f3fd8faf1f5042698ec94d46c5fbd7bbc90f08b3f3
3
+ metadata.gz: 3c5af58e285dcddc7105564342dc34b55531810db4ed351b10d45a1e018ab9f4
4
+ data.tar.gz: 5b03712e5ea784179d552b670875183324135a9835d4bceead9301433f82e8a5
5
5
  SHA512:
6
- metadata.gz: 6b7a903b975a980a23246e944b2e321fab7019ac218c06a51f6645a5e0d7577951cb4bdce9c4319e7184d9b5f031b96320ee874666b8d28c1814850cb59533b9
7
- data.tar.gz: 3a069d2f4d7a69038f3f96153e67911472f43fabb64aa4fc7a3ccdb421684cd6cabd126d208fbe9671dc1fa7c7ecc7f2789d68638f4e84cfdcb78e45d90ddb7b
6
+ metadata.gz: cc3367de9915d2c12ac449a410328a553941341f76bfd2cf8b331c8f22a1cfb0a070f56a6990772ded10893ee1259af4d36f28defe6fc73cb52013a582f0c42e
7
+ data.tar.gz: 78b2f980db593f334e4349ede90bab1d264ab03796ebcbebc4673b79f30670eb9902e748c24b687f0d04250b64d36fbd2455644e7bce9dfddaaf33e9ad6d87b6
data/README.md CHANGED
@@ -20,7 +20,11 @@ The server is a Rails engine mounted inside your application (by default at
20
20
  are the ones you have already configured.
21
21
  - **Queries use Ransack.** The `query` tool passes its arguments straight to
22
22
  [Ransack](https://activerecord-hackery.github.io/ransack/), the same search
23
- library ActiveAdmin uses for filtering.
23
+ library ActiveAdmin uses for filtering. The tool calls `ransack` on the
24
+ model directly rather than going through an ActiveAdmin filter form, so on
25
+ Ransack 4 the model must allowlist the attributes it wants queryable via
26
+ `ransackable_attributes`; without that allowlist, `query` against that
27
+ resource will raise instead of returning an empty result.
24
28
  - **Reads go through ActiveAdmin too.** `list_resources` and `query` run through
25
29
  the same authorization adapter (CanCanCan, Pundit, etc.) as the authenticated
26
30
  MCP user: resources the user cannot read are hidden from the listing and
@@ -28,18 +32,21 @@ The server is a Rails engine mounted inside your application (by default at
28
32
  `scope_collection`, so the MCP user only ever sees the records they could see
29
33
  in the admin UI. With ActiveAdmin's default adapter every check passes, so
30
34
  applications without an authorization adapter are unaffected.
31
- - **Writes go through ActiveAdmin.** The `update` tool only writes fields
32
- allowed by the resource's `permit_params`, refuses resources that don't
33
- register the `update` action, and runs every change through your
34
- authorization adapter as the authenticated MCP user.
35
+ - **Writes go through ActiveAdmin.** The `create` and `update` tools dispatch
36
+ the resource's real ActiveAdmin controller action, so `permit_params`, your
37
+ `before_save`/`after_update` callbacks, the controller's `before_action`
38
+ chain and your authorization adapter all apply exactly as they do when
39
+ someone clicks Save in the admin UI. Resources that don't register the
40
+ action are refused, and `describe_form` will tell a client what a given
41
+ resource's form accepts before it tries.
35
42
  - **Authentication is optional but built in.** Enable Bearer-token auth and the
36
43
  installer adds an "MCP Tokens" management page to your ActiveAdmin panel.
37
44
 
38
45
  ## Requirements
39
46
 
40
- - Ruby >= 3.0
41
- - Rails >= 6.1
42
- - ActiveAdmin >= 2.0
47
+ - Ruby >= 4.0
48
+ - Rails >= 7.2
49
+ - ActiveAdmin ~> 3.5
43
50
 
44
51
  ## Installation
45
52
 
@@ -65,34 +72,323 @@ read/query setup without authentication.
65
72
  |------|-------------|
66
73
  | `list_resources` | List the ActiveAdmin resources the current user may read, along with their attributes. |
67
74
  | `query` | Query a resource the current user may read, using Ransack syntax, scoped to the records they may access (`limit` defaults to 25, capped at 100). |
68
- | `update` | Update an existing record, honouring ActiveAdmin's permitted params and authorization. |
75
+ | `create` | Create a new record through the resource's ActiveAdmin create action, honouring its permitted params, callbacks and authorization. |
76
+ | `update` | Update an existing record through the resource's ActiveAdmin update action, honouring its permitted params, callbacks and authorization. |
77
+ | `describe_form` | Describe the fields of a resource's form — input types, labels, hints, allowed values, column types and which are required — so a `create` or `update` call need not guess them. |
78
+ | *(per action)* | Any ActiveAdmin member, collection or batch action the application has opted in with an `mcp:` option, exposed as its own tool. |
79
+
80
+ The examples below are drawn from the application the end-to-end suite builds
81
+ and drives over HTTP, which is checked in under
82
+ [`spec/e2e/fixture_app/`](spec/e2e/fixture_app/): `Post` permits `title` and
83
+ `body` but not `slug`, `Author` is registered `actions :index, :show`, `Review`
84
+ declares its own form block, and `Tag` declares no `permit_params` at all. Most
85
+ of what this README claims is asserted against that running application in
86
+ [`spec/e2e/`](spec/e2e/), so a claim here can be read next to the example that
87
+ exercises it.
69
88
 
70
89
  ### Query examples
71
90
 
72
91
  ```
73
- Query users whose email contains "example.com"
74
- → query(resource: "User", q: { email_cont: "example.com" })
92
+ Find the post whose title mentions Earthsea
93
+ → query(resource: "Post", q: { title_cont: "Earthsea" })
75
94
 
76
- Find active posts created since the start of the month
77
- → query(resource: "Post", q: { status_eq: "active", created_at_gt: "2026-08-01" })
95
+ Find draft posts created since the start of the month
96
+ → query(resource: "Post", q: { status_eq: "draft", created_at_gt: "2026-08-01" })
97
+
98
+ Look an author up by email
99
+ → query(resource: "Author", q: { email_eq: "ursula@example.com" })
78
100
  ```
79
101
 
80
- ### Updating records
102
+ ### Creating and updating records
81
103
 
82
104
  ```
83
- Update a user's name
84
- → update(resource: "User", id: 42, attributes: { name: "New name" })
105
+ Create a post
106
+ → create(resource: "Post", attributes: { title: "Mort", body: "The fourth." })
107
+
108
+ Retitle a post
109
+ → update(resource: "Post", id: 42, attributes: { title: "Pyramids" })
85
110
  ```
86
111
 
87
- The `update` tool applies the same rules as the ActiveAdmin UI:
112
+ Both tools dispatch the resource's own ActiveAdmin `create` or `update`
113
+ action, so a write from MCP is the same write the admin UI makes:
88
114
 
89
- - **Editable resources only** — resources registered without the `update`
90
- action (e.g. `actions :index, :show`) are refused.
91
- - **Authorization** — the change runs through the resource namespace's
92
- authorization adapter for the authenticated MCP user, so it can only update
93
- what that user is allowed to update in admin.
115
+ - **Registered actions only** — resources registered without the action
116
+ (e.g. `Author`, registered `actions :index, :show`) are refused.
117
+ - **Authorization** — the write runs through the resource namespace's
118
+ authorization adapter for the authenticated MCP user, both before dispatch
119
+ and again inside the controller, so it can only write what that user is
120
+ allowed to write in admin.
94
121
  - **Permitted fields only** — attributes are filtered through the resource's
95
- `permit_params`; fields the admin form doesn't accept are silently dropped.
122
+ `permit_params`; fields the admin form doesn't accept are silently dropped,
123
+ so a `slug` sent to a `Post` permitting only `title` and `body` never
124
+ reaches the record. A resource that declares no `permit_params` at all (like
125
+ `Tag`) is refused outright, with a message saying so — ActiveAdmin cannot
126
+ write such a resource through its own forms either. A block-form
127
+ `permit_params` is evaluated in controller context as the authenticated MCP
128
+ user, so a block varying the writable set by user gets the same answer it
129
+ would give that user in admin.
130
+ - **Your callbacks run** — ActiveAdmin's `before_build`, `before_create`,
131
+ `before_save`, `after_update` and friends all fire, because the controller
132
+ action is what fires them. A `before_create` that fills in a `slug` the form
133
+ never accepted still fills it in.
134
+
135
+ A write rejected by the model comes back as a `Validation failed` error with
136
+ the model's own messages in `details`, and nothing is written.
137
+
138
+ ### Describing a form
139
+
140
+ ```
141
+ What can I set when writing a review?
142
+ → describe_form(resource: "Review") # source: "form"
143
+
144
+ What can I set when creating a post?
145
+ → describe_form(resource: "Post") # source: "permit_params"
146
+ → describe_form(resource: "Post", action: "edit")
147
+ ```
148
+
149
+ `describe_form` reads the resource's own `form do ... end` block when it
150
+ declares one, reporting each input's `as:`, `label:`, `hint:` and — when the
151
+ `collection:` is a literal array — its allowed values. `Review` declares one,
152
+ so its `status` comes back as a `select` labelled "Moderation status" with
153
+ `["pending", "approved"]` as its allowed values. Resources that declare
154
+ no form block get a description derived from their `permit_params` instead:
155
+ ActiveAdmin renders a bare `f.inputs` for those, which Formtastic only expands
156
+ at render time, so there is nothing to read. The response's `source` says which
157
+ of the two you are looking at.
158
+
159
+ Either way every field is annotated from the model with the column type it is
160
+ stored in and whether the model validates its presence. An associated record's
161
+ fields — declared with `has_many`, or with `inputs for: :author` — are reported
162
+ under `nested` rather than flattened in with the record's own, because a write
163
+ against the parent would drop them.
164
+
165
+ A form block that declares no inputs of its own is described from
166
+ `permit_params` too. A bare `f.inputs` is legal, and Formtastic only expands it
167
+ against the model at render time, so there is nothing in the block to read.
168
+
169
+ `action:` selects the gate, not the shape — ActiveAdmin uses one form block for
170
+ both. `"new"` (the default) requires the resource to register `create` and pass
171
+ `create` authorization; `"edit"` requires `update`. Describing a form you could
172
+ never submit tells you nothing you can act on, so it is refused with the same
173
+ messages `create` and `update` use: `Author` is not creatable, and `Tag`
174
+ declares no permitted params.
175
+
176
+ Two limits worth knowing:
177
+
178
+ - A `collection:` that is an `ActiveRecord::Relation` or a proc is omitted
179
+ rather than evaluated. Describing a form should not fire a query, and a
180
+ relation can be arbitrarily large. Use an action's
181
+ [`suggestions:`](#running-member-collection-and-batch-actions) when you want
182
+ dynamic values.
183
+ - A field a form block declares but `permit_params` omits is described and then
184
+ silently dropped on write. This cannot arise on the `permit_params` fallback
185
+ path.
186
+ - On the `permit_params` fallback path, only fields backed by a database column
187
+ are reported. ActiveAdmin keeps no list of the params it permits — only a
188
+ method that filters against them — so the names are recovered by offering it
189
+ every column the model has and seeing which survive. Permitted params that
190
+ are not columns, such as `tag_ids` or a nested `*_attributes` key, are
191
+ therefore missing from the description even though `create` and `update`
192
+ will accept them.
193
+
194
+ ### Running member, collection and batch actions
195
+
196
+ ActiveAdmin actions are **not** exposed by default. An action becomes an MCP
197
+ tool only when you add an `mcp:` option to it:
198
+
199
+ ```ruby
200
+ ActiveAdmin.register Post do
201
+ member_action :publish, method: :post, mcp: {
202
+ description: "Publish a post with the given visibility",
203
+ params: {
204
+ visibility: { type: :string, required: true,
205
+ enum: %w[public unlisted],
206
+ hint: "Who can see the post once published" }
207
+ }
208
+ } do
209
+ # your existing action body, unchanged by opting in
210
+ resource.update!(status: params[:visibility])
211
+ redirect_to resource_path(resource), notice: "Published"
212
+ end
213
+
214
+ # No mcp: key, so this one is not a tool — not listed, and refused by name.
215
+ member_action :archive, method: :post do
216
+ resource.update!(status: "archived")
217
+ redirect_to resource_path(resource), notice: "Archived"
218
+ end
219
+ end
220
+ ```
221
+
222
+ That registers a `post_publish` tool, and no `post_archive` tool. Batch actions
223
+ opt in the same way, and inherit their param types from the `form:` hash you
224
+ already declare:
225
+
226
+ ```ruby
227
+ batch_action :set_status, form: { status: :text },
228
+ mcp: { description: "Set the status on the selected posts" } do |ids, inputs|
229
+ Post.where(id: ids).update_all(status: inputs["status"])
230
+ end
231
+ ```
232
+
233
+ That registers a `post_set_status` tool taking an `ids` array alongside the
234
+ `status` string it took from `form:`.
235
+
236
+ **Declaring params**
237
+
238
+ - `type:` — one of `:string`, `:integer`, `:number`, `:boolean`, `:array`, `:object`.
239
+ - `required:` — refuses the call when the value is missing.
240
+ - `hint:` — static guidance shown to the agent. Always a plain string.
241
+ - `enum:` — **binding**. A value outside the list is refused before dispatch.
242
+ - `suggestions:` — a proc evaluated when tools are listed, e.g.
243
+ `suggestions: -> { Post.distinct.pluck(:status) }`. **Advisory only**, never
244
+ enforced, so use it for live values from the database. If it raises, the tool
245
+ is still listed without suggestions.
246
+
247
+ On a batch action, every declared param must also appear in the action's `form:`
248
+ hash. ActiveAdmin slices submitted inputs down to the declared `form:` keys
249
+ before calling the block, so a param declared only under `mcp:` would be
250
+ advertised to the client and then dropped; the declaration is refused instead.
251
+
252
+ **Authorization**
253
+
254
+ `permission:` is an *additional* gate, never a replacement. Every call first
255
+ passes your ActiveAdmin authorization adapter exactly as `query` and `update`
256
+ do; the proc can only narrow access further, never widen it. It is evaluated in
257
+ controller context, so `current_admin_user`, `can?` and the usual admin helpers
258
+ are available. Return `false` to refuse, or a `String` to refuse with a reason
259
+ the agent can act on.
260
+
261
+ ```ruby
262
+ # Takes the record, so it can only be resolved when the call arrives.
263
+ member_action :feature, method: :post, mcp: {
264
+ description: "Feature a published post on the front page",
265
+ permission: ->(post) { post.status == "draft" ? "Only a published post can be featured" : true }
266
+ } do
267
+ resource.update!(status: "featured")
268
+ end
269
+
270
+ # Takes no arguments, so it is resolved when tools are listed.
271
+ collection_action :purge_drafts, method: :post, mcp: {
272
+ description: "Purge every draft post",
273
+ permission: -> { current_admin_user&.editor? }
274
+ } do
275
+ redirect_to collection_path, notice: "Purged"
276
+ end
277
+ ```
278
+
279
+ Tools are also listed per user: an action whose resource the adapter refuses is
280
+ left out of `tools/list` entirely, and a `permission:` proc that takes no
281
+ arguments is evaluated at listing time (in the same controller context) so the
282
+ tool is hidden rather than offered and then refused. A proc that takes the
283
+ record cannot be resolved without one, so `post_feature` stays advertised and
284
+ refuses at call time with the string the proc returned.
285
+
286
+ For **batch actions** the adapter check is necessarily resource-level — there is
287
+ no single record to authorize — so it is `authorized?(:<action>, YourModel)`
288
+ rather than a per-record policy evaluation. To stop that being a hole, the ids
289
+ the client submits are run back through the adapter's `scope_collection`, and
290
+ the whole call is refused if any of them falls outside the scope. Nothing is
291
+ narrowed silently: the call either acts on every id you asked for or on none.
292
+
293
+ One caveat on authentication. Dispatch neutralises the namespace's
294
+ `authentication_method` callback, because the MCP request has already
295
+ authenticated by bearer token and that callback would otherwise redirect to a
296
+ login page. If your `authentication_method` is a *combined* authentication-and-
297
+ authorization method — one that also, say, rejects non-superusers — then
298
+ neutralising it disables that authorization half too. Resource-level
299
+ authorization still runs through the adapter, but the "we only skip
300
+ authentication" framing is not universal; keep authorization in the adapter,
301
+ not in the authentication callback.
302
+
303
+ **What you get back**
304
+
305
+ Actions are executed through your real ActiveAdmin controller, so the action's
306
+ `before_action` chain, authorization and callbacks all run. The tool returns the
307
+ response status, the redirect target and any flash messages — not the rendered
308
+ HTML. An action whose body raises comes back as a generic `Post#publish failed`
309
+ naming the resource and action, with the exception's own message — which can
310
+ carry SQL, table names and file paths — kept away from the client.
311
+
312
+ Redirect-style (submit-side) actions are the supported case; a `GET` action that
313
+ renders a full admin view is best-effort and may fail for want of a view
314
+ context.
315
+
316
+ ### Actions declared somewhere you can't add `mcp:`
317
+
318
+ An action declared by a shared concern, or by another gem, has no declaration
319
+ you can hang an `mcp:` key on — and if it did, every resource including it
320
+ would get the same description, params and `permission:` proc. Annotate it by
321
+ name from the registration instead, with `mcp_action`:
322
+
323
+ ```ruby
324
+ module Flaggable
325
+ def self.included(dsl)
326
+ dsl.send(:member_action, :flag, method: [:post, :delete]) { ... }
327
+ dsl.send(:batch_action, :flag, form: proc { { reason: :text } }) { |ids, inputs| ... }
328
+ end
329
+ end
330
+
331
+ ActiveAdmin.register Volunteer do
332
+ include Flaggable
333
+
334
+ mcp_action :flag, kind: :batch, tool_name: "volunteer_bulk_flag",
335
+ description: "Flag the selected volunteers",
336
+ params: { reason: { type: :string, required: true } }
337
+
338
+ mcp_action :flag, kind: :member, tool_name: "volunteer_flag",
339
+ description: "Flag a volunteer",
340
+ params: { reason: { type: :string, required: true } }
341
+
342
+ mcp_action :flag, kind: :member, http_verb: :delete, tool_name: "volunteer_unflag",
343
+ description: "Remove a volunteer's flag"
344
+ end
345
+ ```
346
+
347
+ `mcp_action` takes everything `mcp:` takes, plus:
348
+
349
+ | Option | Meaning |
350
+ |--------|---------|
351
+ | `kind:` | `:member`, `:collection` or `:batch`. Optional; needed only when one name belongs to more than one action, which is refused rather than guessed. |
352
+ | `tool_name:` | The MCP tool name, in place of the derived `<resource>_<action>`. |
353
+ | `http_verb:` | Which verb to dispatch, for an action declared with several (`method: [:post, :delete]`). A verb the action does not answer to is a declaration error. |
354
+
355
+ It **annotates**; it never declares. Naming an action the resource does not
356
+ have warns and skips. It is resolved when the tool list is built, not when it
357
+ is called, so it may appear above or below the `include`.
358
+
359
+ An annotation **replaces** an inline `mcp:` declaration rather than merging
360
+ into it, so a shared generic declaration and a per-resource one cannot
361
+ half-combine into something neither author wrote.
362
+
363
+ An action may carry more than one annotation, each producing its own tool —
364
+ which is how an action answering to two verbs, one undoing the other, becomes
365
+ two tools.
366
+
367
+ **Opting in is still per resource.** Two resources including the same concern
368
+ share the actions, not the exposure: whichever does not annotate exposes
369
+ nothing.
370
+
371
+ **Names must not collide.** A tool name carries no kind, so a `member_action`
372
+ and a `batch_action` of the same name derive the same one. Rather than let one
373
+ silently shadow the other, both are hidden until a `tool_name:` tells them
374
+ apart.
375
+
376
+ **Batch actions declared with a String title** — the ones applications generate
377
+ in loops from data — may be annotated by that title, rather than by the symbol
378
+ ActiveAdmin derives from it by titleizing and underscoring, which can carry
379
+ punctuation. The derived tool name has that punctuation squeezed out.
380
+
381
+ **ActiveAdmin's `:if` proc is honoured.** A batch action the admin UI hides
382
+ because its `:if` refuses is neither listed nor runnable over MCP. ActiveAdmin
383
+ itself consults `:if` only when rendering, so this is stricter than ActiveAdmin
384
+ is — deliberately: MCP should not be the way round a gate the admin enforces by
385
+ not offering the button. A proc that raises, typically because it reads request
386
+ state a tool listing cannot supply, hides the tool and says so in the log.
387
+
388
+ **A proc `form:` is evaluated** in controller context, the way ActiveAdmin
389
+ evaluates it, so a batch action whose form varies by resource still contributes
390
+ its param types. Like `suggestions:`, this runs application code, and is never
391
+ evaluated for a user the resource's authorization adapter refuses.
96
392
 
97
393
  ## Connecting a client
98
394
 
@@ -231,16 +527,20 @@ npx @anthropic-ai/mcpb pack . ../activeadmin-mcp.mcpb
231
527
  ```
232
528
 
233
529
  That writes `activeadmin-mcp.mcpb` (a zip of `manifest.json`, `package.json` and
234
- `server/index.js`) to the repository root, ready to distribute. Bump `version`
235
- in **both** `mcpb/manifest.json` and `mcpb/package.json` before packing a
236
- release — Claude Desktop uses the manifest version to detect upgrades.
530
+ `server/index.js`) to the repository root. You do not bump the bundle's
531
+ `version` by hand: publishing a GitHub Release writes the release tag into
532
+ `mcpb/manifest.json` and `mcpb/package.json`, packs the bundle from that, and
533
+ attaches `activeadmin-mcp-<version>.mcpb` to the release — so the bundle
534
+ version Claude Desktop uses to detect upgrades always matches the gem version.
535
+ See [RELEASING.md](RELEASING.md).
237
536
 
238
- CI packs the bundle on every push and attaches it as a build artifact, so you
239
- can also download a build from the Actions tab rather than packing it yourself.
537
+ So the bundle for any released version is on that release's page, and CI packs
538
+ the bundle on every push and attaches it as a build artifact if you want an
539
+ unreleased build from the Actions tab.
240
540
 
241
- Distribute the file however suits you: an internal file share, a GitHub release
242
- asset, or an S3 bucket. Anyone with the file can install it, but it is inert
243
- without a token.
541
+ Distribute the file however suits you — pointing colleagues at the release
542
+ asset, an internal file share, or an S3 bucket. Anyone with the file can
543
+ install it, but it is inert without a token.
244
544
 
245
545
  ### Installing
246
546
 
@@ -284,7 +584,10 @@ installed bundle simply starts reporting an authentication failure.
284
584
  ## Configuration
285
585
 
286
586
  The generator writes an initializer to
287
- `config/initializers/activeadmin_mcp.rb`:
587
+ `config/initializers/activeadmin_mcp.rb` carrying every option below,
588
+ commented out. Running it with `--auth devise_token` uncomments the
589
+ authentication method for you, so the first edit is usually just naming your
590
+ Devise model:
288
591
 
289
592
  ```ruby
290
593
  ActiveadminMcp.configure do |config|
@@ -344,6 +647,44 @@ bundle install
344
647
  bundle exec rspec
345
648
  ```
346
649
 
650
+ ### Running the tests
651
+
652
+ `bundle exec rspec` (or `rake spec`, the default rake task) runs the unit
653
+ suite only — it's fast, and it's what CI's `rspec` job runs. It never touches
654
+ `spec/e2e`; that directory is excluded via `.rspec`.
655
+
656
+ The end-to-end suite lives in `spec/e2e` and runs separately:
657
+
658
+ ```bash
659
+ bundle exec rake e2e
660
+ ```
661
+
662
+ This generates a real Rails 7.2 + ActiveAdmin + Devise application under
663
+ `tmp/e2e_app`, installs the gem into it with `path:`, boots it under Puma,
664
+ and drives it over real HTTP with a minimal JSON-RPC client — the same way
665
+ Claude Code or any other MCP client would. It's how the gem is tested
666
+ against ActiveAdmin's and Ransack's actual behaviour rather than mocks.
667
+
668
+ The registrations, models, migrations and seeds that application uses are
669
+ checked in under [`spec/e2e/fixture_app/`](spec/e2e/fixture_app/) rather than
670
+ written from heredocs, and they are the `Post`, `Author`, `Review` and `Tag`
671
+ this README's examples are written against — so a claim above can be read next
672
+ to both the registration it describes and the example that exercises it.
673
+ [`spec/e2e/fixture_app/README.md`](spec/e2e/fixture_app/README.md) says which
674
+ claim each fixture exists to support.
675
+
676
+ The generated application is expensive to build (a full `bundle install`
677
+ against rubygems.org, and a `gem install rails` if Rails 7.2.2.2 isn't
678
+ already on your system) so it's cached in `tmp/e2e_app` between runs, keyed
679
+ on the contents of `spec/e2e/support/app_builder.rb`. The first run costs a
680
+ few minutes and needs network access; edit that file and the next run
681
+ rebuilds from scratch, otherwise reruns are quick. Force a rebuild without
682
+ editing anything by setting `E2E_REBUILD=1`:
683
+
684
+ ```bash
685
+ E2E_REBUILD=1 bundle exec rake e2e
686
+ ```
687
+
347
688
  ## Contributing
348
689
 
349
690
  Bug reports and pull requests are welcome on GitHub.
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  module ActiveadminMcp
4
2
  class McpController < ActionController::API
5
3
  before_action :authenticate_mcp_token!
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  require "digest"
4
2
  require "securerandom"
5
3
 
data/config/routes.rb CHANGED
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  ActiveadminMcp::Engine.routes.draw do
4
2
  post "/", to: "mcp#call"
5
3
  end
@@ -0,0 +1,165 @@
1
+ module ActiveadminMcp
2
+ # Finds every ActiveAdmin action an application has opted in to MCP.
3
+ #
4
+ # Nothing is cached: ActiveAdmin reloads resources in development, and a
5
+ # stale catalog would advertise tools that no longer exist.
6
+ module ActionCatalog
7
+ # Tool names the engine reserves for itself, including names Phase 2 and 3
8
+ # will take, so an application cannot silently shadow one later.
9
+ RESERVED = %w[list_resources query update create describe_form].freeze
10
+
11
+ class << self
12
+ # The authenticated user is carried through so a batch action's `form:`
13
+ # proc can be evaluated in controller context, the way ActiveAdmin
14
+ # evaluates it. Nothing here evaluates it — see ActionDefinition#params,
15
+ # which does, lazily, once the caller has authorized the action.
16
+ def all(current_user: nil)
17
+ without_colliding_names(
18
+ ResourceRegistry.resources.flat_map do |entry|
19
+ definitions_for(entry[:config], current_user)
20
+ end
21
+ )
22
+ end
23
+
24
+ def find(tool_name, current_user: nil)
25
+ all(current_user: current_user).find { |definition| definition.tool_name == tool_name }
26
+ end
27
+
28
+ private
29
+
30
+ # A tool name carries no kind, so a member and a batch action of the same
31
+ # name derive the same one — which ActiveAdmin allows, and a shared
32
+ # concern declaring both is how it happens in practice. Advertising a
33
+ # duplicate would leave whichever the catalog found second permanently
34
+ # unreachable, since find returns the first match. Refuse both instead,
35
+ # and say which name, so the declaration can choose a tool_name.
36
+ def without_colliding_names(definitions)
37
+ definitions.group_by(&:tool_name).flat_map do |tool_name, sharing|
38
+ next sharing if sharing.one?
39
+
40
+ warn_and_skip("#{sharing.length} actions would both be called #{tool_name}; " \
41
+ "give all but one an explicit tool_name:")
42
+ []
43
+ end
44
+ end
45
+
46
+ def definitions_for(config, current_user = nil)
47
+ annotated, inline = partition_declarations(config)
48
+
49
+ (annotated + inline).filter_map do |action, kind, options|
50
+ definition = ActionDefinition.new(
51
+ config: config, action: action, kind: kind, options: options, current_user: current_user
52
+ )
53
+
54
+ next warn_and_skip(definition.errors.join("; ")) unless definition.valid?
55
+ next warn_and_skip("#{definition.tool_name} collides with a built-in tool") if reserved?(definition)
56
+
57
+ definition
58
+ end
59
+ end
60
+
61
+ # Resolves each mcp_action annotation to the action it names, and leaves
62
+ # every unannotated action to its own inline mcp: declaration. An
63
+ # annotation replaces an inline declaration wholesale rather than merging
64
+ # into it: one declaration wins, and it is visible which.
65
+ def partition_declarations(config)
66
+ actions = candidates(config)
67
+ annotated = []
68
+ claimed = []
69
+
70
+ safe_annotations(config).each do |annotation|
71
+ matches = matching_actions(actions, annotation)
72
+
73
+ next warn_and_skip(annotation_missing(config, annotation)) if matches.empty?
74
+ next warn_and_skip(annotation_ambiguous(config, annotation)) unless matches.one?
75
+
76
+ action, kind = matches.first
77
+ claimed << action
78
+ annotated << [action, kind, annotation[:options]]
79
+ end
80
+
81
+ inline = actions.filter_map do |action, kind|
82
+ # By identity, not equality: ActiveAdmin::ControllerAction is
83
+ # Comparable through a `priority` its instances do not all have, so
84
+ # asking an Array whether it includes one can raise.
85
+ next if claimed.any? { |claimed_action| claimed_action.equal?(action) }
86
+
87
+ options = action.mcp_options
88
+ [action, kind, options] if options.is_a?(Hash)
89
+ end
90
+
91
+ [annotated, inline]
92
+ end
93
+
94
+ def matching_actions(actions, annotation)
95
+ wanted = annotation[:action_name].to_s
96
+
97
+ actions.select do |action, kind|
98
+ names_of(action, kind).include?(wanted) &&
99
+ (annotation[:kind].nil? || annotation[:kind] == kind)
100
+ end
101
+ end
102
+
103
+ # A batch action may be declared with a String title, from which
104
+ # ActiveAdmin derives the symbol by titleizing, stripping spaces and
105
+ # underscoring — which can leave punctuation in it. Applications generate
106
+ # those in loops from data, so an annotation may name either the title it
107
+ # wrote or the symbol ActiveAdmin made of it.
108
+ def names_of(action, kind)
109
+ names = [declared_name(action, kind).to_s]
110
+ names << action.title.to_s if kind == :batch && action.respond_to?(:title)
111
+ names
112
+ end
113
+
114
+ def declared_name(action, kind)
115
+ (kind == :batch ? action.sym : action.name).to_sym
116
+ end
117
+
118
+ def annotation_missing(config, annotation)
119
+ "#{resource_name(config)} annotates #{annotation[:action_name]}, which it does not declare"
120
+ end
121
+
122
+ def annotation_ambiguous(config, annotation)
123
+ "#{resource_name(config)} annotates #{annotation[:action_name]}, which names more than one " \
124
+ "action; say which with kind:"
125
+ end
126
+
127
+ def resource_name(config)
128
+ config.resource_class.name
129
+ end
130
+
131
+ def safe_annotations(config)
132
+ config.respond_to?(:mcp_annotations) ? Array(config.mcp_annotations) : []
133
+ end
134
+
135
+ def candidates(config)
136
+ pairs = []
137
+ pairs.concat(safe_actions(config, :member_actions).map { |a| [a, :member] })
138
+ pairs.concat(safe_actions(config, :collection_actions).map { |a| [a, :collection] })
139
+ pairs.concat(safe_actions(config, :batch_actions).map { |a| [a, :batch] }) if batch_enabled?(config)
140
+ pairs.select { |action, _kind| action.respond_to?(:mcp_options) }
141
+ end
142
+
143
+ def safe_actions(config, reader)
144
+ config.respond_to?(reader) ? Array(config.public_send(reader)) : []
145
+ end
146
+
147
+ # ActiveAdmin keeps registered batch actions even when the namespace has
148
+ # batch actions switched off, and hides them in the UI. Match that.
149
+ def batch_enabled?(config)
150
+ return false unless config.respond_to?(:batch_actions_enabled?)
151
+
152
+ config.batch_actions_enabled?
153
+ end
154
+
155
+ def reserved?(definition)
156
+ RESERVED.include?(definition.tool_name)
157
+ end
158
+
159
+ def warn_and_skip(message)
160
+ warn("[activeadmin_mcp] ignoring action: #{message}")
161
+ nil
162
+ end
163
+ end
164
+ end
165
+ end