Skip to content

Commit f9d79a2

Browse files
lloydwatkinclaude
andcommitted
Add update tool respecting ActiveAdmin form and authorization
Adds a basic `update` MCP tool that modifies a single record, enforcing the same rules as the ActiveAdmin UI: - editable resources only (resource must expose the :update action) - authorization via the namespace's authorization adapter for the authenticated MCP user - only fields the resource's permit_params allows are written, driven through ActiveAdmin's own permitted_params Also threads the current MCP user through RequestHandler and exposes the ActiveAdmin resource config from ResourceRegistry.find so the updater can inspect actions/authorization. Specs added for RecordUpdater and the update tool; existing specs updated for the new tools/list entry and ResourceRegistry.find contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2e1fb98 commit f9d79a2

9 files changed

Lines changed: 336 additions & 6 deletions

File tree

‎README.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ Or add to your `.mcp.json`:
187187
|------|-------------|
188188
| `list_resources` | List all ActiveAdmin resources with their attributes |
189189
| `query` | Query a resource using Ransack syntax |
190+
| `update` | Update an existing record, respecting ActiveAdmin's form and authorization rules |
190191

191192
### Query Examples
192193

@@ -198,6 +199,24 @@ Find active posts from last week
198199
→ query(resource: "Post", q: { status_eq: "active", created_at_gt: "2025-12-01" })
199200
```
200201

202+
### Updating Records
203+
204+
```
205+
Update a user's name
206+
→ update(resource: "User", id: 42, attributes: { name: "New name" })
207+
```
208+
209+
The `update` tool applies the same rules as the ActiveAdmin UI:
210+
211+
- **Editable resources only** — resources registered without the `update` action
212+
(e.g. `actions :index, :show`) are refused.
213+
- **Authorization** — the update is run through the resource namespace's
214+
authorization adapter for the authenticated MCP user (CanCanCan, Pundit, etc.),
215+
so a user can only update what they're allowed to in admin.
216+
- **Permitted fields only** — attributes are filtered through the resource's
217+
`permit_params`, so only fields the admin form accepts are written; anything
218+
else is silently dropped.
219+
201220
## Original Project
202221

203222
We forked this project from [https://github.com/betacraft/active_admin_mcp] originally and have continued to extend from there.

‎app/controllers/active_admin_mcp/mcp_controller.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ class McpController < ActionController::API
88

99
def call
1010
request_body = JSON.parse(request.body.read)
11-
response = RequestHandler.new.handle(request_body)
11+
response = RequestHandler.new(current_user: current_mcp_user).handle(request_body)
1212

1313
response ? render(json: response) : head(:no_content)
1414
rescue JSON::ParserError => e

‎lib/active_admin_mcp.rb‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require_relative "active_admin_mcp/version"
44
require_relative "active_admin_mcp/configuration"
55
require_relative "active_admin_mcp/resource_registry"
6+
require_relative "active_admin_mcp/record_updater"
67
require_relative "active_admin_mcp/request_handler"
78
require_relative "active_admin_mcp/engine"
89

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveAdminMcp
4+
# Updates a single ActiveAdmin-managed record, enforcing the same three gates
5+
# the admin UI would: the resource must expose the update action, the current
6+
# user must be authorized, and only fields the admin form permits are written.
7+
class RecordUpdater
8+
UPDATE = :update
9+
10+
# Raised internally when the resource's permitted params cannot be resolved.
11+
class PermitError < StandardError; end
12+
13+
def initialize(resource:, current_user:)
14+
@resource = resource
15+
@current_user = current_user
16+
end
17+
18+
def call(id:, attributes:)
19+
config = @resource[:config]
20+
21+
return error("Resource is not editable: #{@resource[:name]}") unless editable?(config)
22+
23+
record = @resource[:model].find_by(id: id)
24+
return error("Record not found: #{@resource[:name]}##{id}") unless record
25+
26+
unless authorized?(config, record)
27+
return error("Not authorized to update #{@resource[:name]}##{id}")
28+
end
29+
30+
begin
31+
permitted = permitted_attributes(config, attributes)
32+
rescue PermitError => e
33+
return error(e.message)
34+
end
35+
return error("No permitted attributes to update") if permitted.empty?
36+
37+
if record.update(permitted)
38+
{
39+
resource: @resource[:name],
40+
id: record.id,
41+
updated: permitted.keys,
42+
record: record.as_json,
43+
}
44+
else
45+
error("Validation failed", details: record.errors.full_messages)
46+
end
47+
end
48+
49+
private
50+
51+
def editable?(config)
52+
config.defined_actions.include?(UPDATE)
53+
end
54+
55+
def authorized?(config, record)
56+
adapter_class = config.namespace.authorization_adapter
57+
adapter_class = adapter_class.constantize if adapter_class.is_a?(String)
58+
adapter_class.new(config, @current_user).authorized?(UPDATE, record)
59+
end
60+
61+
# Runs the incoming attributes through the resource controller's own
62+
# permitted_params (compiled from `permit_params`), so we accept exactly what
63+
# the admin form accepts. Fails closed if that cannot be resolved.
64+
def permitted_attributes(config, attributes)
65+
param_key = config.param_key.to_sym
66+
controller = config.controller.new
67+
68+
unless controller.respond_to?(:permitted_params, true)
69+
raise PermitError, "Resource does not declare permit_params: #{@resource[:name]}"
70+
end
71+
72+
controller.params = ActionController::Parameters.new(param_key => attributes)
73+
permitted = controller.send(:permitted_params)[param_key]
74+
permitted ? permitted.to_h.symbolize_keys : {}
75+
rescue PermitError
76+
raise
77+
rescue StandardError => e
78+
raise PermitError, "Could not determine permitted attributes: #{e.message}"
79+
end
80+
81+
def error(message, details: nil)
82+
result = { error: message }
83+
result[:details] = details if details
84+
result
85+
end
86+
end
87+
end

‎lib/active_admin_mcp/request_handler.rb‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ module ActiveAdminMcp
44
class RequestHandler
55
PROTOCOL_VERSION = "2025-06-18"
66

7+
def initialize(current_user: nil)
8+
@current_user = current_user
9+
end
10+
711
def handle(request)
812
id = request["id"]
913
method = request["method"]
@@ -56,6 +60,20 @@ def tools_list
5660
required: ["resource"],
5761
},
5862
},
63+
{
64+
name: "update",
65+
description: "Update an existing record. Only fields the resource's ActiveAdmin " \
66+
"form permits are written, and the update respects ActiveAdmin authorization.",
67+
inputSchema: {
68+
type: "object",
69+
properties: {
70+
resource: { type: "string", description: "Resource name (e.g., 'User', 'Post')" },
71+
id: { type: ["integer", "string"], description: "Primary key of the record to update" },
72+
attributes: { type: "object", description: "Attributes to update (e.g., {name: 'New name'})" },
73+
},
74+
required: %w[resource id attributes],
75+
},
76+
},
5977
],
6078
}
6179
end
@@ -67,6 +85,7 @@ def call_tool(params)
6785
result = case name
6886
when "list_resources" then tool_list_resources
6987
when "query" then tool_query(args)
88+
when "update" then tool_update(args)
7089
else { error: "Unknown tool: #{name}" }
7190
end
7291

@@ -88,6 +107,18 @@ def tool_query(args)
88107
{ resource: resource[:name], count: records.size, records: records.as_json }
89108
end
90109

110+
def tool_update(args)
111+
resource = ResourceRegistry.find(args["resource"])
112+
return { error: "Resource not found: #{args['resource']}" } unless resource
113+
return { error: "id is required" } if args["id"].nil?
114+
115+
attributes = args["attributes"] || {}
116+
return { error: "attributes are required" } if attributes.empty?
117+
118+
RecordUpdater.new(resource: resource, current_user: @current_user)
119+
.call(id: args["id"], attributes: attributes)
120+
end
121+
91122
def success(id, result)
92123
{ jsonrpc: "2.0", id: id, result: result }
93124
end

‎lib/active_admin_mcp/resource_registry.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ def find(name)
1111
resource = discover.find { |r| r.resource_class.name == name }
1212
return unless resource
1313

14-
{ name: resource.resource_class.name, model: resource.resource_class }
14+
{ name: resource.resource_class.name, model: resource.resource_class, config: resource }
1515
end
1616

1717
private
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
RSpec.describe ActiveAdminMcp::RecordUpdater do
6+
# A stand-in for an ActiveAdmin controller compiled from `permit_params`.
7+
# `permitted` is the set of fields the admin form would allow.
8+
def build_controller(param_key:, permitted:)
9+
Class.new do
10+
attr_accessor :params
11+
12+
define_method(:permitted_params) do
13+
params.permit(param_key => permitted)
14+
end
15+
private :permitted_params
16+
end
17+
end
18+
19+
def build_resource(model:, actions: %i[index show new create edit update destroy],
20+
adapter:, param_key: :widget, permitted: %i[name role])
21+
namespace = double("namespace", authorization_adapter: adapter)
22+
config = double(
23+
"config",
24+
defined_actions: actions,
25+
namespace: namespace,
26+
controller: build_controller(param_key: param_key, permitted: permitted),
27+
param_key: param_key,
28+
)
29+
{ name: "Widget", model: model, config: config }
30+
end
31+
32+
def model_finding(record, id: 1)
33+
double("model").tap { |m| allow(m).to receive(:find_by).with(id: id).and_return(record) }
34+
end
35+
36+
let(:permit_all) do
37+
Class.new do
38+
def initialize(*); end
39+
def authorized?(*) = true
40+
end
41+
end
42+
43+
let(:deny_all) do
44+
Class.new do
45+
def initialize(*); end
46+
def authorized?(*) = false
47+
end
48+
end
49+
50+
def update(resource, id: 1, attributes:, current_user: :admin)
51+
described_class.new(resource: resource, current_user: current_user).call(id: id, attributes: attributes)
52+
end
53+
54+
it "writes permitted attributes and returns the updated record" do
55+
record = double("record", id: 1, as_json: { "id" => 1, "name" => "Renamed" })
56+
allow(record).to receive(:update).and_return(true)
57+
resource = build_resource(model: model_finding(record), adapter: permit_all)
58+
59+
result = update(resource, attributes: { "name" => "Renamed" })
60+
61+
expect(record).to have_received(:update).with(name: "Renamed")
62+
expect(result[:updated]).to eq([:name])
63+
expect(result[:record]).to eq("id" => 1, "name" => "Renamed")
64+
end
65+
66+
it "drops attributes the admin form does not permit" do
67+
record = double("record", id: 1, as_json: {})
68+
allow(record).to receive(:update).and_return(true)
69+
resource = build_resource(model: model_finding(record), adapter: permit_all, permitted: %i[name])
70+
71+
update(resource, attributes: { "name" => "Renamed", "role" => "admin" })
72+
73+
expect(record).to have_received(:update).with(name: "Renamed")
74+
end
75+
76+
it "refuses when ActiveAdmin does not expose the update action" do
77+
resource = build_resource(model: double("model"), adapter: permit_all, actions: %i[index show])
78+
79+
result = update(resource, attributes: { "name" => "Renamed" })
80+
81+
expect(result[:error]).to match(/not editable/i)
82+
end
83+
84+
it "refuses when the user is not authorized to update the record" do
85+
record = double("record", id: 1)
86+
resource = build_resource(model: model_finding(record), adapter: deny_all)
87+
88+
result = update(resource, attributes: { "name" => "Renamed" })
89+
90+
expect(result[:error]).to match(/not authorized/i)
91+
end
92+
93+
it "returns an error when the record does not exist" do
94+
resource = build_resource(model: model_finding(nil, id: 999), adapter: permit_all)
95+
96+
result = update(resource, id: 999, attributes: { "name" => "Renamed" })
97+
98+
expect(result[:error]).to match(/not found/i)
99+
end
100+
101+
it "returns validation errors when the update is rejected" do
102+
record = double("record", id: 1, errors: double("errors", full_messages: ["Name can't be blank"]))
103+
allow(record).to receive(:update).and_return(false)
104+
resource = build_resource(model: model_finding(record), adapter: permit_all)
105+
106+
result = update(resource, attributes: { "name" => "" })
107+
108+
expect(result[:error]).to match(/validation/i)
109+
expect(result[:details]).to eq(["Name can't be blank"])
110+
end
111+
112+
it "returns an error when no permitted attributes remain after filtering" do
113+
record = double("record", id: 1)
114+
resource = build_resource(model: model_finding(record), adapter: permit_all, permitted: %i[name])
115+
116+
result = update(resource, attributes: { "role" => "admin" })
117+
118+
expect(result[:error]).to match(/no permitted attributes/i)
119+
end
120+
121+
it "updates an attribute literally named 'error' without treating it as a failure" do
122+
record = double("record", id: 1, as_json: { "id" => 1, "error" => "boom" })
123+
allow(record).to receive(:update).and_return(true)
124+
resource = build_resource(model: model_finding(record), adapter: permit_all, permitted: %i[error])
125+
126+
result = update(resource, attributes: { "error" => "boom" })
127+
128+
expect(record).to have_received(:update).with(error: "boom")
129+
expect(result[:updated]).to eq([:error])
130+
end
131+
end

0 commit comments

Comments
 (0)