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 +4 -4
- data/README.md +373 -32
- data/app/controllers/activeadmin_mcp/mcp_controller.rb +0 -2
- data/app/models/activeadmin_mcp/api_token.rb +0 -2
- data/config/routes.rb +0 -2
- data/lib/activeadmin_mcp/action_catalog.rb +165 -0
- data/lib/activeadmin_mcp/action_definition.rb +297 -0
- data/lib/activeadmin_mcp/action_params.rb +78 -0
- data/lib/activeadmin_mcp/action_runner.rb +184 -0
- data/lib/activeadmin_mcp/action_schema.rb +66 -0
- data/lib/activeadmin_mcp/active_admin_ext.rb +83 -0
- data/lib/activeadmin_mcp/authorization.rb +0 -2
- data/lib/activeadmin_mcp/configuration.rb +0 -2
- data/lib/activeadmin_mcp/controller_dispatcher.rb +138 -0
- data/lib/activeadmin_mcp/engine.rb +12 -2
- data/lib/activeadmin_mcp/form_description.rb +157 -0
- data/lib/activeadmin_mcp/form_field_collector.rb +84 -17
- data/lib/activeadmin_mcp/record_writer.rb +167 -0
- data/lib/activeadmin_mcp/request_handler.rb +167 -38
- data/lib/activeadmin_mcp/resource_registry.rb +0 -2
- data/lib/activeadmin_mcp/version.rb +1 -3
- data/lib/activeadmin_mcp.rb +9 -3
- data/lib/generators/activeadmin_mcp/install/install_generator.rb +0 -2
- data/lib/generators/activeadmin_mcp/install/templates/initializer.rb +5 -2
- data/lib/generators/activeadmin_mcp/install/templates/mcp_api_tokens.rb +0 -2
- data/lib/generators/activeadmin_mcp/install/templates/migration.rb.erb +0 -2
- metadata +18 -10
- data/lib/activeadmin_mcp/record_updater.rb +0 -108
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3c5af58e285dcddc7105564342dc34b55531810db4ed351b10d45a1e018ab9f4
|
|
4
|
+
data.tar.gz: 5b03712e5ea784179d552b670875183324135a9835d4bceead9301433f82e8a5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 `
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
authorization adapter as
|
|
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 >=
|
|
41
|
-
- Rails >=
|
|
42
|
-
- ActiveAdmin
|
|
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
|
-
| `
|
|
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
|
-
|
|
74
|
-
→ query(resource: "
|
|
92
|
+
Find the post whose title mentions Earthsea
|
|
93
|
+
→ query(resource: "Post", q: { title_cont: "Earthsea" })
|
|
75
94
|
|
|
76
|
-
Find
|
|
77
|
-
→ query(resource: "Post", q: { status_eq: "
|
|
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
|
-
###
|
|
102
|
+
### Creating and updating records
|
|
81
103
|
|
|
82
104
|
```
|
|
83
|
-
|
|
84
|
-
→
|
|
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
|
-
|
|
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
|
-
- **
|
|
90
|
-
|
|
91
|
-
- **Authorization** — the
|
|
92
|
-
authorization adapter for the authenticated MCP user,
|
|
93
|
-
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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
|
|
242
|
-
asset, or an S3 bucket. Anyone with the file can
|
|
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.
|
data/config/routes.rb
CHANGED
|
@@ -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
|