activeadmin_mcp 0.0.4 → 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 +4 -4
- data/README.md +280 -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 +64 -0
- data/lib/activeadmin_mcp/action_definition.rb +183 -0
- data/lib/activeadmin_mcp/action_params.rb +78 -0
- data/lib/activeadmin_mcp/action_runner.rb +163 -0
- data/lib/activeadmin_mcp/action_schema.rb +66 -0
- data/lib/activeadmin_mcp/active_admin_ext.rb +39 -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 +6 -2
- data/lib/activeadmin_mcp/form_description.rb +149 -0
- data/lib/activeadmin_mcp/form_field_collector.rb +68 -15
- data/lib/activeadmin_mcp/record_writer.rb +161 -0
- data/lib/activeadmin_mcp/request_handler.rb +149 -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: aac4aa9e2af5d377f711090b2e8adad79915844e26842733f5c8af6b02f37d7b
|
|
4
|
+
data.tar.gz: c418625d7b5bff3c9e555d0d248e098c08a8ac1a224644e900fa14fa3a95dfa7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 `
|
|
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,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
|
-
| `
|
|
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.
|
|
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
|
|
|
@@ -231,16 +434,20 @@ npx @anthropic-ai/mcpb pack . ../activeadmin-mcp.mcpb
|
|
|
231
434
|
```
|
|
232
435
|
|
|
233
436
|
That writes `activeadmin-mcp.mcpb` (a zip of `manifest.json`, `package.json` and
|
|
234
|
-
`server/index.js`) to the repository root
|
|
235
|
-
|
|
236
|
-
|
|
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).
|
|
237
443
|
|
|
238
|
-
|
|
239
|
-
|
|
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.
|
|
240
447
|
|
|
241
|
-
Distribute the file however suits you
|
|
242
|
-
asset, or an S3 bucket. Anyone with the file can
|
|
243
|
-
without a token.
|
|
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.
|
|
244
451
|
|
|
245
452
|
### Installing
|
|
246
453
|
|
|
@@ -284,7 +491,10 @@ installed bundle simply starts reporting an authentication failure.
|
|
|
284
491
|
## Configuration
|
|
285
492
|
|
|
286
493
|
The generator writes an initializer to
|
|
287
|
-
`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:
|
|
288
498
|
|
|
289
499
|
```ruby
|
|
290
500
|
ActiveadminMcp.configure do |config|
|
|
@@ -344,6 +554,44 @@ bundle install
|
|
|
344
554
|
bundle exec rspec
|
|
345
555
|
```
|
|
346
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
|
+
|
|
347
595
|
## Contributing
|
|
348
596
|
|
|
349
597
|
Bug reports and pull requests are welcome on GitHub.
|
data/config/routes.rb
CHANGED
|
@@ -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
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
module ActiveadminMcp
|
|
2
|
+
# One ActiveAdmin action that an application has opted in to MCP, normalised
|
|
3
|
+
# so the rest of the engine does not care whether it came from a
|
|
4
|
+
# member_action, a collection_action or a batch_action.
|
|
5
|
+
#
|
|
6
|
+
# Batch actions are the special case: ActiveAdmin already knows their input
|
|
7
|
+
# names and widget types via `form:`, so those become param types and the
|
|
8
|
+
# `mcp:` declaration only layers descriptions and hints on top.
|
|
9
|
+
class ActionDefinition
|
|
10
|
+
KINDS = %i[member collection batch].freeze
|
|
11
|
+
|
|
12
|
+
# JSON Schema scalar types an application may declare.
|
|
13
|
+
TYPES = %i[string integer number boolean array object].freeze
|
|
14
|
+
|
|
15
|
+
# ActiveAdmin batch action form widgets -> JSON Schema types.
|
|
16
|
+
FORM_TYPES = {
|
|
17
|
+
text: :string,
|
|
18
|
+
string: :string,
|
|
19
|
+
select: :string,
|
|
20
|
+
datepicker: :string,
|
|
21
|
+
number: :number,
|
|
22
|
+
checkbox: :boolean,
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
attr_reader :config, :action, :kind, :errors
|
|
26
|
+
|
|
27
|
+
def self.build(config:, action:, kind:)
|
|
28
|
+
options = action.mcp_options
|
|
29
|
+
return nil unless options.is_a?(Hash)
|
|
30
|
+
|
|
31
|
+
new(config: config, action: action, kind: kind, options: options)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(config:, action:, kind:, options:)
|
|
35
|
+
@config = config
|
|
36
|
+
@action = action
|
|
37
|
+
@kind = kind
|
|
38
|
+
@options = options
|
|
39
|
+
@errors = []
|
|
40
|
+
validate!
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def action_name
|
|
44
|
+
(@kind == :batch ? @action.sym : @action.name).to_sym
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def resource_name
|
|
48
|
+
@config.resource_class.name
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def tool_name
|
|
52
|
+
"#{resource_name.underscore.tr('/', '_')}_#{action_name}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def description
|
|
56
|
+
@options[:description]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def permission
|
|
60
|
+
@options[:permission]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def http_verb
|
|
64
|
+
return :post if @kind == :batch
|
|
65
|
+
|
|
66
|
+
Array(@action.http_verb).first&.to_sym || :get
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Declared params, with batch actions inheriting their types from the
|
|
70
|
+
# ActiveAdmin `form:` hash underneath anything the declaration says.
|
|
71
|
+
def params
|
|
72
|
+
@params ||= inherited_params.merge(declared_params) do |_key, inherited, declared|
|
|
73
|
+
inherited.merge(declared)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def valid?
|
|
78
|
+
@errors.empty?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def declared_params
|
|
84
|
+
raw = @options[:params]
|
|
85
|
+
return {} unless raw.is_a?(Hash)
|
|
86
|
+
|
|
87
|
+
raw.each_with_object({}) { |(name, spec), acc| acc[name.to_sym] = spec }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def inherited_params
|
|
91
|
+
return {} unless @kind == :batch
|
|
92
|
+
return {} unless @action.respond_to?(:inputs)
|
|
93
|
+
|
|
94
|
+
form = @action.inputs
|
|
95
|
+
return {} unless form.is_a?(Hash)
|
|
96
|
+
|
|
97
|
+
form.each_with_object({}) do |(name, widget), acc|
|
|
98
|
+
acc[name.to_sym] = { type: FORM_TYPES.fetch(widget.to_sym, :string) }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def validate!
|
|
103
|
+
@errors << "#{tool_name}: mcp declaration needs a description" if description.to_s.strip.empty?
|
|
104
|
+
@errors << "#{tool_name}: unknown kind #{@kind}" unless KINDS.include?(@kind)
|
|
105
|
+
|
|
106
|
+
reserved = reserved_param_name
|
|
107
|
+
form_keys = batch_form_keys
|
|
108
|
+
params.each do |name, spec|
|
|
109
|
+
unless spec.is_a?(Hash)
|
|
110
|
+
@errors << "#{tool_name}: param #{name} must be a Hash"
|
|
111
|
+
next
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
@errors << "#{tool_name}: param #{name} is reserved" if name == reserved
|
|
115
|
+
|
|
116
|
+
# ActiveAdmin's own batch_action controller method slices the submitted
|
|
117
|
+
# inputs down to the keys of the batch action's `inputs` (its `form:`
|
|
118
|
+
# hash) before calling the block: `inputs.slice(*valid_keys)`. When
|
|
119
|
+
# there is no `form:` at all, `inputs` is nil, so `valid_keys` is nil,
|
|
120
|
+
# and `slice(*nil)` is `slice()` — which drops EVERY input, not none.
|
|
121
|
+
# So a form-less batch action permits nothing, and any declared param
|
|
122
|
+
# is a declaration error. A Proc form is evaluated by ActiveAdmin in
|
|
123
|
+
# controller context (MethodOrProcHelper.render_in_context), so we
|
|
124
|
+
# cannot know its keys here and skip the check rather than guess.
|
|
125
|
+
case form_keys
|
|
126
|
+
when :unknown_proc_form
|
|
127
|
+
nil
|
|
128
|
+
when :no_form
|
|
129
|
+
@errors << "#{tool_name}: param #{name} cannot be declared because this batch action " \
|
|
130
|
+
"has no form: hash, so ActiveAdmin drops every input before the action runs"
|
|
131
|
+
when nil
|
|
132
|
+
nil # not a batch action, or no `inputs` method at all: no rule applies
|
|
133
|
+
else
|
|
134
|
+
unless form_keys.include?(name)
|
|
135
|
+
@errors << "#{tool_name}: param #{name} is not among the batch action's declared " \
|
|
136
|
+
"form: keys, so ActiveAdmin would drop it before the action runs"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
type = spec[:type]
|
|
141
|
+
@errors << "#{tool_name}: param #{name} has unknown type #{type}" if type && !TYPES.include?(type.to_sym)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
@errors << "#{tool_name}: permission must be callable" if permission && !permission.respond_to?(:call)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The permitted param keys for a batch action's `inputs` (its `form:`
|
|
148
|
+
# hash), distinguishing three outcomes the caller must treat differently:
|
|
149
|
+
#
|
|
150
|
+
# * not a batch action, or the action has no `inputs` method at all ->
|
|
151
|
+
# nil, the batch form: rule does not apply.
|
|
152
|
+
# * `inputs` is a Hash -> its keys, the permitted set ActiveAdmin will
|
|
153
|
+
# slice submitted params down to.
|
|
154
|
+
# * `inputs` is nil (no `form:` declared) -> :no_form. ActiveAdmin still
|
|
155
|
+
# slices, against a nil key list, which yields an EMPTY permitted set
|
|
156
|
+
# (`hash.slice(*nil)` is `hash.slice()` == `{}`), so every declared
|
|
157
|
+
# param here is a declaration error.
|
|
158
|
+
# * `inputs` is a Proc -> :unknown_proc_form. ActiveAdmin evaluates it in
|
|
159
|
+
# controller context via `render_in_context`, so we cannot know its
|
|
160
|
+
# keys at declaration time. We skip the check rather than guess.
|
|
161
|
+
def batch_form_keys
|
|
162
|
+
return nil unless @kind == :batch
|
|
163
|
+
return nil unless @action.respond_to?(:inputs)
|
|
164
|
+
|
|
165
|
+
form = @action.inputs
|
|
166
|
+
return form.keys.map(&:to_sym) if form.is_a?(Hash)
|
|
167
|
+
return :unknown_proc_form if form.is_a?(Proc)
|
|
168
|
+
|
|
169
|
+
:no_form
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def reserved_param_name
|
|
173
|
+
case @kind
|
|
174
|
+
when :member
|
|
175
|
+
:id
|
|
176
|
+
when :batch
|
|
177
|
+
:ids
|
|
178
|
+
else
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|