activeadmin_mcp 0.0.3 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1d97e042cb6e4af51f5939d4edd39a96f7193ece948a05a70dd8e18851f1bc85
4
- data.tar.gz: 6b91b93d2d0511f3bea310f62044589eb35b90358547e942d379be214582d5e7
3
+ metadata.gz: aac4aa9e2af5d377f711090b2e8adad79915844e26842733f5c8af6b02f37d7b
4
+ data.tar.gz: c418625d7b5bff3c9e555d0d248e098c08a8ac1a224644e900fa14fa3a95dfa7
5
5
  SHA512:
6
- metadata.gz: 6714a33bbef7072a22f067f422837e6ccf88ebd6a835157c8ffa8fff5487791ee2494fbcd1316ac4ca65c9767e53ac6e48a7c672e84846615fb77af29b507080
7
- data.tar.gz: fb49f14f716cd9c5a5e0b9ca80c546adaaa7fcab15b8867965c7d823084f456580982270185c65861477c11840c219cbdf41fd676a597c2b39c4835ec7d0f5f4
6
+ metadata.gz: aea999468c061e6d532b0edb1e3241393a78dc34c1f94c0b07c2108431ab4370b4c89d45940a6691dce0ce5bfbc6903f92bc087457fc4d557fd70a97dacc9278
7
+ data.tar.gz: 588af72e3801e3c2c728d0407e217c7ebf54277d07a8dbdbd518fdbfe7e9cb335601cc3afe90cbcbbe76af40ef55b6bddde982ce11632107aca957875e34e464
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,230 @@ 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.
127
+ - **Your callbacks run** — ActiveAdmin's `before_build`, `before_create`,
128
+ `before_save`, `after_update` and friends all fire, because the controller
129
+ action is what fires them. A `before_create` that fills in a `slug` the form
130
+ never accepted still fills it in.
131
+
132
+ A write rejected by the model comes back as a `Validation failed` error with
133
+ the model's own messages in `details`, and nothing is written.
134
+
135
+ ### Describing a form
136
+
137
+ ```
138
+ What can I set when writing a review?
139
+ → describe_form(resource: "Review") # source: "form"
140
+
141
+ What can I set when creating a post?
142
+ → describe_form(resource: "Post") # source: "permit_params"
143
+ → describe_form(resource: "Post", action: "edit")
144
+ ```
145
+
146
+ `describe_form` reads the resource's own `form do ... end` block when it
147
+ declares one, reporting each input's `as:`, `label:`, `hint:` and — when the
148
+ `collection:` is a literal array — its allowed values. `Review` declares one,
149
+ so its `status` comes back as a `select` labelled "Moderation status" with
150
+ `["pending", "approved"]` as its allowed values. Resources that declare
151
+ no form block get a description derived from their `permit_params` instead:
152
+ ActiveAdmin renders a bare `f.inputs` for those, which Formtastic only expands
153
+ at render time, so there is nothing to read. The response's `source` says which
154
+ of the two you are looking at.
155
+
156
+ Either way every field is annotated from the model with the column type it is
157
+ stored in and whether the model validates its presence. `has_many` blocks are
158
+ reported under `nested` rather than flattened in with the record's own fields.
159
+
160
+ `action:` selects the gate, not the shape — ActiveAdmin uses one form block for
161
+ both. `"new"` (the default) requires the resource to register `create` and pass
162
+ `create` authorization; `"edit"` requires `update`. Describing a form you could
163
+ never submit tells you nothing you can act on, so it is refused with the same
164
+ messages `create` and `update` use: `Author` is not creatable, and `Tag`
165
+ declares no permitted params.
166
+
167
+ Two limits worth knowing:
168
+
169
+ - A `collection:` that is an `ActiveRecord::Relation` or a proc is omitted
170
+ rather than evaluated. Describing a form should not fire a query, and a
171
+ relation can be arbitrarily large. Use an action's
172
+ [`suggestions:`](#running-member-collection-and-batch-actions) when you want
173
+ dynamic values.
174
+ - A field a form block declares but `permit_params` omits is described and then
175
+ silently dropped on write. This cannot arise on the `permit_params` fallback
176
+ path.
177
+
178
+ ### Running member, collection and batch actions
179
+
180
+ ActiveAdmin actions are **not** exposed by default. An action becomes an MCP
181
+ tool only when you add an `mcp:` option to it:
182
+
183
+ ```ruby
184
+ ActiveAdmin.register Post do
185
+ member_action :publish, method: :post, mcp: {
186
+ description: "Publish a post with the given visibility",
187
+ params: {
188
+ visibility: { type: :string, required: true,
189
+ enum: %w[public unlisted],
190
+ hint: "Who can see the post once published" }
191
+ }
192
+ } do
193
+ # your existing action body, unchanged by opting in
194
+ resource.update!(status: params[:visibility])
195
+ redirect_to resource_path(resource), notice: "Published"
196
+ end
197
+
198
+ # No mcp: key, so this one is not a tool — not listed, and refused by name.
199
+ member_action :archive, method: :post do
200
+ resource.update!(status: "archived")
201
+ redirect_to resource_path(resource), notice: "Archived"
202
+ end
203
+ end
204
+ ```
205
+
206
+ That registers a `post_publish` tool, and no `post_archive` tool. Batch actions
207
+ opt in the same way, and inherit their param types from the `form:` hash you
208
+ already declare:
209
+
210
+ ```ruby
211
+ batch_action :set_status, form: { status: :text },
212
+ mcp: { description: "Set the status on the selected posts" } do |ids, inputs|
213
+ Post.where(id: ids).update_all(status: inputs["status"])
214
+ end
215
+ ```
216
+
217
+ That registers a `post_set_status` tool taking an `ids` array alongside the
218
+ `status` string it took from `form:`.
219
+
220
+ **Declaring params**
221
+
222
+ - `type:` — one of `:string`, `:integer`, `:number`, `:boolean`, `:array`, `:object`.
223
+ - `required:` — refuses the call when the value is missing.
224
+ - `hint:` — static guidance shown to the agent. Always a plain string.
225
+ - `enum:` — **binding**. A value outside the list is refused before dispatch.
226
+ - `suggestions:` — a proc evaluated when tools are listed, e.g.
227
+ `suggestions: -> { Post.distinct.pluck(:status) }`. **Advisory only**, never
228
+ enforced, so use it for live values from the database. If it raises, the tool
229
+ is still listed without suggestions.
230
+
231
+ On a batch action, every declared param must also appear in the action's `form:`
232
+ hash. ActiveAdmin slices submitted inputs down to the declared `form:` keys
233
+ before calling the block, so a param declared only under `mcp:` would be
234
+ advertised to the client and then dropped; the declaration is refused instead.
235
+
236
+ **Authorization**
237
+
238
+ `permission:` is an *additional* gate, never a replacement. Every call first
239
+ passes your ActiveAdmin authorization adapter exactly as `query` and `update`
240
+ do; the proc can only narrow access further, never widen it. It is evaluated in
241
+ controller context, so `current_admin_user`, `can?` and the usual admin helpers
242
+ are available. Return `false` to refuse, or a `String` to refuse with a reason
243
+ the agent can act on.
244
+
245
+ ```ruby
246
+ # Takes the record, so it can only be resolved when the call arrives.
247
+ member_action :feature, method: :post, mcp: {
248
+ description: "Feature a published post on the front page",
249
+ permission: ->(post) { post.status == "draft" ? "Only a published post can be featured" : true }
250
+ } do
251
+ resource.update!(status: "featured")
252
+ end
253
+
254
+ # Takes no arguments, so it is resolved when tools are listed.
255
+ collection_action :purge_drafts, method: :post, mcp: {
256
+ description: "Purge every draft post",
257
+ permission: -> { current_admin_user&.editor? }
258
+ } do
259
+ redirect_to collection_path, notice: "Purged"
260
+ end
261
+ ```
262
+
263
+ Tools are also listed per user: an action whose resource the adapter refuses is
264
+ left out of `tools/list` entirely, and a `permission:` proc that takes no
265
+ arguments is evaluated at listing time (in the same controller context) so the
266
+ tool is hidden rather than offered and then refused. A proc that takes the
267
+ record cannot be resolved without one, so `post_feature` stays advertised and
268
+ refuses at call time with the string the proc returned.
269
+
270
+ For **batch actions** the adapter check is necessarily resource-level — there is
271
+ no single record to authorize — so it is `authorized?(:<action>, YourModel)`
272
+ rather than a per-record policy evaluation. To stop that being a hole, the ids
273
+ the client submits are run back through the adapter's `scope_collection`, and
274
+ the whole call is refused if any of them falls outside the scope. Nothing is
275
+ narrowed silently: the call either acts on every id you asked for or on none.
276
+
277
+ One caveat on authentication. Dispatch neutralises the namespace's
278
+ `authentication_method` callback, because the MCP request has already
279
+ authenticated by bearer token and that callback would otherwise redirect to a
280
+ login page. If your `authentication_method` is a *combined* authentication-and-
281
+ authorization method — one that also, say, rejects non-superusers — then
282
+ neutralising it disables that authorization half too. Resource-level
283
+ authorization still runs through the adapter, but the "we only skip
284
+ authentication" framing is not universal; keep authorization in the adapter,
285
+ not in the authentication callback.
286
+
287
+ **What you get back**
288
+
289
+ Actions are executed through your real ActiveAdmin controller, so the action's
290
+ `before_action` chain, authorization and callbacks all run. The tool returns the
291
+ response status, the redirect target and any flash messages — not the rendered
292
+ HTML. An action whose body raises comes back as a generic `Post#publish failed`
293
+ naming the resource and action, with the exception's own message — which can
294
+ carry SQL, table names and file paths — kept away from the client.
295
+
296
+ Redirect-style (submit-side) actions are the supported case; a `GET` action that
297
+ renders a full admin view is best-effort and may fail for want of a view
298
+ context.
96
299
 
97
300
  ## Connecting a client
98
301
 
@@ -203,10 +406,95 @@ end
203
406
  }
204
407
  ```
205
408
 
409
+ ## Claude Desktop (MCPB bundle)
410
+
411
+ Claude Code talks to the server over HTTP directly, but **Claude Desktop** cannot:
412
+ its remote connector UI has no way to send an API token header. The `mcpb/`
413
+ directory solves this with an
414
+ [MCP bundle](https://claude.com/docs/connectors/building/mcpb) — a `.mcpb` file
415
+ your colleagues install with a double-click.
416
+
417
+ Inside the bundle is a small Node script that speaks stdio to Claude Desktop and
418
+ forwards every message, unchanged, to your server over HTTPS with the token
419
+ attached. It has no dependencies and adds no capabilities of its own, so the
420
+ tools it exposes are exactly the ones your server exposes.
421
+
422
+ ```
423
+ Claude Desktop ──stdio──▶ mcpb proxy ──HTTPS + token──▶ your Rails app
424
+ ```
425
+
426
+ ### Building the bundle
427
+
428
+ Requires Node 18 or newer. From the repository root:
429
+
430
+ ```bash
431
+ cd mcpb
432
+ npm test # no dependencies to install
433
+ npx @anthropic-ai/mcpb pack . ../activeadmin-mcp.mcpb
434
+ ```
435
+
436
+ That writes `activeadmin-mcp.mcpb` (a zip of `manifest.json`, `package.json` and
437
+ `server/index.js`) to the repository root. You do not bump the bundle's
438
+ `version` by hand: publishing a GitHub Release writes the release tag into
439
+ `mcpb/manifest.json` and `mcpb/package.json`, packs the bundle from that, and
440
+ attaches `activeadmin-mcp-<version>.mcpb` to the release — so the bundle
441
+ version Claude Desktop uses to detect upgrades always matches the gem version.
442
+ See [RELEASING.md](RELEASING.md).
443
+
444
+ So the bundle for any released version is on that release's page, and CI packs
445
+ the bundle on every push and attaches it as a build artifact if you want an
446
+ unreleased build from the Actions tab.
447
+
448
+ Distribute the file however suits you — pointing colleagues at the release
449
+ asset, an internal file share, or an S3 bucket. Anyone with the file can
450
+ install it, but it is inert without a token.
451
+
452
+ ### Installing
453
+
454
+ 1. **Generate a token.** Sign in to your admin panel, go to **MCP Tokens**, name
455
+ the token after the machine you are installing on (e.g. "Claude Desktop —
456
+ work laptop") and copy it. It is shown only once.
457
+ 2. **Install the bundle.** Double-click the `.mcpb` file, or drag it onto the
458
+ Claude Desktop window, or use **Settings → Extensions → Advanced settings →
459
+ Install Extension…**.
460
+ 3. **Fill in the three settings** Claude Desktop prompts for:
461
+
462
+ | Setting | Value |
463
+ |---------|-------|
464
+ | Server URL | The full MCP endpoint, e.g. `https://admin.example.com/admin/mcp` |
465
+ | API token | The token from step 1 (stored in the OS keychain, never shown again) |
466
+ | Authentication header | `Authorization`, unless your app sets a custom [`auth_header_name`](#custom-auth-header) |
467
+
468
+ 4. **Check it works.** Start a new chat and ask Claude to list the admin
469
+ resources it can see. You should get back the resources your account can read.
470
+
471
+ Installation is per-person: each colleague installs the bundle and generates
472
+ their own token, so every MCP call is attributed to them and constrained by
473
+ their own admin permissions.
474
+
475
+ ### Revoking access
476
+
477
+ A token is a long-lived credential granting everything that user can do in
478
+ admin. Revoke one from the same **MCP Tokens** page — the `Last used` column
479
+ shows which tokens are still live. Revoking takes effect immediately; the
480
+ installed bundle simply starts reporting an authentication failure.
481
+
482
+ ### Troubleshooting
483
+
484
+ | Symptom | Cause |
485
+ |---------|-------|
486
+ | "The server rejected the API token (HTTP 401)" | The token is wrong, revoked, or being sent in the wrong header. Check the **Authentication header** setting matches your `auth_header_name`. |
487
+ | "Could not reach …" | The URL is wrong or unreachable from this machine — check VPN, and that the URL includes the full mount path. |
488
+ | The extension shows no tools | Claude Desktop only refreshes tools on connect. Toggle the extension off and on in Settings → Extensions. |
489
+ | Anything else | Claude Desktop's extension logs carry the proxy's stderr output, each line prefixed `[activeadmin_mcp]`. |
490
+
206
491
  ## Configuration
207
492
 
208
493
  The generator writes an initializer to
209
- `config/initializers/activeadmin_mcp.rb`:
494
+ `config/initializers/activeadmin_mcp.rb` carrying every option below,
495
+ commented out. Running it with `--auth devise_token` uncomments the
496
+ authentication method for you, so the first edit is usually just naming your
497
+ Devise model:
210
498
 
211
499
  ```ruby
212
500
  ActiveadminMcp.configure do |config|
@@ -266,6 +554,44 @@ bundle install
266
554
  bundle exec rspec
267
555
  ```
268
556
 
557
+ ### Running the tests
558
+
559
+ `bundle exec rspec` (or `rake spec`, the default rake task) runs the unit
560
+ suite only — it's fast, and it's what CI's `rspec` job runs. It never touches
561
+ `spec/e2e`; that directory is excluded via `.rspec`.
562
+
563
+ The end-to-end suite lives in `spec/e2e` and runs separately:
564
+
565
+ ```bash
566
+ bundle exec rake e2e
567
+ ```
568
+
569
+ This generates a real Rails 7.2 + ActiveAdmin + Devise application under
570
+ `tmp/e2e_app`, installs the gem into it with `path:`, boots it under Puma,
571
+ and drives it over real HTTP with a minimal JSON-RPC client — the same way
572
+ Claude Code or any other MCP client would. It's how the gem is tested
573
+ against ActiveAdmin's and Ransack's actual behaviour rather than mocks.
574
+
575
+ The registrations, models, migrations and seeds that application uses are
576
+ checked in under [`spec/e2e/fixture_app/`](spec/e2e/fixture_app/) rather than
577
+ written from heredocs, and they are the `Post`, `Author`, `Review` and `Tag`
578
+ this README's examples are written against — so a claim above can be read next
579
+ to both the registration it describes and the example that exercises it.
580
+ [`spec/e2e/fixture_app/README.md`](spec/e2e/fixture_app/README.md) says which
581
+ claim each fixture exists to support.
582
+
583
+ The generated application is expensive to build (a full `bundle install`
584
+ against rubygems.org, and a `gem install rails` if Rails 7.2.2.2 isn't
585
+ already on your system) so it's cached in `tmp/e2e_app` between runs, keyed
586
+ on the contents of `spec/e2e/support/app_builder.rb`. The first run costs a
587
+ few minutes and needs network access; edit that file and the next run
588
+ rebuilds from scratch, otherwise reruns are quick. Force a rebuild without
589
+ editing anything by setting `E2E_REBUILD=1`:
590
+
591
+ ```bash
592
+ E2E_REBUILD=1 bundle exec rake e2e
593
+ ```
594
+
269
595
  ## Contributing
270
596
 
271
597
  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,64 @@
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
+ def all
13
+ ResourceRegistry.resources.flat_map { |entry| definitions_for(entry[:config]) }
14
+ end
15
+
16
+ def find(tool_name)
17
+ all.find { |definition| definition.tool_name == tool_name }
18
+ end
19
+
20
+ private
21
+
22
+ def definitions_for(config)
23
+ candidates(config).filter_map do |action, kind|
24
+ definition = ActionDefinition.build(config: config, action: action, kind: kind)
25
+ next unless definition
26
+
27
+ next warn_and_skip(definition.errors.join("; ")) unless definition.valid?
28
+ next warn_and_skip("#{definition.tool_name} collides with a built-in tool") if reserved?(definition)
29
+
30
+ definition
31
+ end
32
+ end
33
+
34
+ def candidates(config)
35
+ pairs = []
36
+ pairs.concat(safe_actions(config, :member_actions).map { |a| [a, :member] })
37
+ pairs.concat(safe_actions(config, :collection_actions).map { |a| [a, :collection] })
38
+ pairs.concat(safe_actions(config, :batch_actions).map { |a| [a, :batch] }) if batch_enabled?(config)
39
+ pairs.select { |action, _kind| action.respond_to?(:mcp_options) }
40
+ end
41
+
42
+ def safe_actions(config, reader)
43
+ config.respond_to?(reader) ? Array(config.public_send(reader)) : []
44
+ end
45
+
46
+ # ActiveAdmin keeps registered batch actions even when the namespace has
47
+ # batch actions switched off, and hides them in the UI. Match that.
48
+ def batch_enabled?(config)
49
+ return false unless config.respond_to?(:batch_actions_enabled?)
50
+
51
+ config.batch_actions_enabled?
52
+ end
53
+
54
+ def reserved?(definition)
55
+ RESERVED.include?(definition.tool_name)
56
+ end
57
+
58
+ def warn_and_skip(message)
59
+ warn("[activeadmin_mcp] ignoring action: #{message}")
60
+ nil
61
+ end
62
+ end
63
+ end
64
+ end