Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ Or add to your `.mcp.json`:
|------|-------------|
| `list_resources` | List all ActiveAdmin resources with their attributes |
| `query` | Query a resource using Ransack syntax |
| `update` | Update an existing record, respecting ActiveAdmin's form and authorization rules |

### Query Examples

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

### Updating Records

```
Update a user's name
→ update(resource: "User", id: 42, attributes: { name: "New name" })
```

The `update` tool applies the same rules as the ActiveAdmin UI:

- **Editable resources only** — resources registered without the `update` action
(e.g. `actions :index, :show`) are refused.
- **Authorization** — the update is run through the resource namespace's
authorization adapter for the authenticated MCP user (CanCanCan, Pundit, etc.),
so a user can only update what they're allowed to in admin.
- **Permitted fields only** — attributes are filtered through the resource's
`permit_params`, so only fields the admin form accepts are written; anything
else is silently dropped.

## Original Project

We forked this project from [https://github.com/betacraft/active_admin_mcp] originally and have continued to extend from there.
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/active_admin_mcp/mcp_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class McpController < ActionController::API

def call
request_body = JSON.parse(request.body.read)
response = RequestHandler.new.handle(request_body)
response = RequestHandler.new(current_user: current_mcp_user).handle(request_body)

response ? render(json: response) : head(:no_content)
rescue JSON::ParserError => e
Expand Down
1 change: 1 addition & 0 deletions lib/active_admin_mcp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require_relative "active_admin_mcp/version"
require_relative "active_admin_mcp/configuration"
require_relative "active_admin_mcp/resource_registry"
require_relative "active_admin_mcp/record_updater"
require_relative "active_admin_mcp/request_handler"
require_relative "active_admin_mcp/engine"

Expand Down
87 changes: 87 additions & 0 deletions lib/active_admin_mcp/record_updater.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# frozen_string_literal: true

module ActiveAdminMcp
# Updates a single ActiveAdmin-managed record, enforcing the same three gates
# the admin UI would: the resource must expose the update action, the current
# user must be authorized, and only fields the admin form permits are written.
class RecordUpdater
UPDATE = :update

# Raised internally when the resource's permitted params cannot be resolved.
class PermitError < StandardError; end

def initialize(resource:, current_user:)
@resource = resource
@current_user = current_user
end

def call(id:, attributes:)
config = @resource[:config]

return error("Resource is not editable: #{@resource[:name]}") unless editable?(config)

record = @resource[:model].find_by(id: id)
return error("Record not found: #{@resource[:name]}##{id}") unless record

unless authorized?(config, record)
return error("Not authorized to update #{@resource[:name]}##{id}")
end

begin
permitted = permitted_attributes(config, attributes)
rescue PermitError => e
return error(e.message)
end
return error("No permitted attributes to update") if permitted.empty?

if record.update(permitted)
{
resource: @resource[:name],
id: record.id,
updated: permitted.keys,
record: record.as_json,
}
else
error("Validation failed", details: record.errors.full_messages)
end
end

private

def editable?(config)
config.defined_actions.include?(UPDATE)
end

def authorized?(config, record)
adapter_class = config.namespace.authorization_adapter
adapter_class = adapter_class.constantize if adapter_class.is_a?(String)
adapter_class.new(config, @current_user).authorized?(UPDATE, record)
end

# Runs the incoming attributes through the resource controller's own
# permitted_params (compiled from `permit_params`), so we accept exactly what
# the admin form accepts. Fails closed if that cannot be resolved.
def permitted_attributes(config, attributes)
param_key = config.param_key.to_sym
controller = config.controller.new

unless controller.respond_to?(:permitted_params, true)
raise PermitError, "Resource does not declare permit_params: #{@resource[:name]}"
end

controller.params = ActionController::Parameters.new(param_key => attributes)
permitted = controller.send(:permitted_params)[param_key]
permitted ? permitted.to_h.symbolize_keys : {}
rescue PermitError
raise
rescue StandardError => e
raise PermitError, "Could not determine permitted attributes: #{e.message}"
end

def error(message, details: nil)
result = { error: message }
result[:details] = details if details
result
end
end
end
31 changes: 31 additions & 0 deletions lib/active_admin_mcp/request_handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ module ActiveAdminMcp
class RequestHandler
PROTOCOL_VERSION = "2025-06-18"

def initialize(current_user: nil)
@current_user = current_user
end

def handle(request)
id = request["id"]
method = request["method"]
Expand Down Expand Up @@ -56,6 +60,20 @@ def tools_list
required: ["resource"],
},
},
{
name: "update",
description: "Update an existing record. Only fields the resource's ActiveAdmin " \
"form permits are written, and the update respects ActiveAdmin authorization.",
inputSchema: {
type: "object",
properties: {
resource: { type: "string", description: "Resource name (e.g., 'User', 'Post')" },
id: { type: ["integer", "string"], description: "Primary key of the record to update" },
attributes: { type: "object", description: "Attributes to update (e.g., {name: 'New name'})" },
},
required: %w[resource id attributes],
},
},
],
}
end
Expand All @@ -67,6 +85,7 @@ def call_tool(params)
result = case name
when "list_resources" then tool_list_resources
when "query" then tool_query(args)
when "update" then tool_update(args)
else { error: "Unknown tool: #{name}" }
end

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

def tool_update(args)
resource = ResourceRegistry.find(args["resource"])
return { error: "Resource not found: #{args['resource']}" } unless resource
return { error: "id is required" } if args["id"].nil?

attributes = args["attributes"] || {}
return { error: "attributes are required" } if attributes.empty?

RecordUpdater.new(resource: resource, current_user: @current_user)
.call(id: args["id"], attributes: attributes)
end

def success(id, result)
{ jsonrpc: "2.0", id: id, result: result }
end
Expand Down
2 changes: 1 addition & 1 deletion lib/active_admin_mcp/resource_registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def find(name)
resource = discover.find { |r| r.resource_class.name == name }
return unless resource

{ name: resource.resource_class.name, model: resource.resource_class }
{ name: resource.resource_class.name, model: resource.resource_class, config: resource }
end

private
Expand Down
131 changes: 131 additions & 0 deletions spec/active_admin_mcp/record_updater_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe ActiveAdminMcp::RecordUpdater do
# A stand-in for an ActiveAdmin controller compiled from `permit_params`.
# `permitted` is the set of fields the admin form would allow.
def build_controller(param_key:, permitted:)
Class.new do
attr_accessor :params

define_method(:permitted_params) do
params.permit(param_key => permitted)
end
private :permitted_params
end
end

def build_resource(model:, actions: %i[index show new create edit update destroy],
adapter:, param_key: :widget, permitted: %i[name role])
namespace = double("namespace", authorization_adapter: adapter)
config = double(
"config",
defined_actions: actions,
namespace: namespace,
controller: build_controller(param_key: param_key, permitted: permitted),
param_key: param_key,
)
{ name: "Widget", model: model, config: config }
end

def model_finding(record, id: 1)
double("model").tap { |m| allow(m).to receive(:find_by).with(id: id).and_return(record) }
end

let(:permit_all) do
Class.new do
def initialize(*); end
def authorized?(*) = true
end
end

let(:deny_all) do
Class.new do
def initialize(*); end
def authorized?(*) = false
end
end

def update(resource, id: 1, attributes:, current_user: :admin)
described_class.new(resource: resource, current_user: current_user).call(id: id, attributes: attributes)
end

it "writes permitted attributes and returns the updated record" do
record = double("record", id: 1, as_json: { "id" => 1, "name" => "Renamed" })
allow(record).to receive(:update).and_return(true)
resource = build_resource(model: model_finding(record), adapter: permit_all)

result = update(resource, attributes: { "name" => "Renamed" })

expect(record).to have_received(:update).with(name: "Renamed")
expect(result[:updated]).to eq([:name])
expect(result[:record]).to eq("id" => 1, "name" => "Renamed")
end

it "drops attributes the admin form does not permit" do
record = double("record", id: 1, as_json: {})
allow(record).to receive(:update).and_return(true)
resource = build_resource(model: model_finding(record), adapter: permit_all, permitted: %i[name])

update(resource, attributes: { "name" => "Renamed", "role" => "admin" })

expect(record).to have_received(:update).with(name: "Renamed")
end

it "refuses when ActiveAdmin does not expose the update action" do
resource = build_resource(model: double("model"), adapter: permit_all, actions: %i[index show])

result = update(resource, attributes: { "name" => "Renamed" })

expect(result[:error]).to match(/not editable/i)
end

it "refuses when the user is not authorized to update the record" do
record = double("record", id: 1)
resource = build_resource(model: model_finding(record), adapter: deny_all)

result = update(resource, attributes: { "name" => "Renamed" })

expect(result[:error]).to match(/not authorized/i)
end

it "returns an error when the record does not exist" do
resource = build_resource(model: model_finding(nil, id: 999), adapter: permit_all)

result = update(resource, id: 999, attributes: { "name" => "Renamed" })

expect(result[:error]).to match(/not found/i)
end

it "returns validation errors when the update is rejected" do
record = double("record", id: 1, errors: double("errors", full_messages: ["Name can't be blank"]))
allow(record).to receive(:update).and_return(false)
resource = build_resource(model: model_finding(record), adapter: permit_all)

result = update(resource, attributes: { "name" => "" })

expect(result[:error]).to match(/validation/i)
expect(result[:details]).to eq(["Name can't be blank"])
end

it "returns an error when no permitted attributes remain after filtering" do
record = double("record", id: 1)
resource = build_resource(model: model_finding(record), adapter: permit_all, permitted: %i[name])

result = update(resource, attributes: { "role" => "admin" })

expect(result[:error]).to match(/no permitted attributes/i)
end

it "updates an attribute literally named 'error' without treating it as a failure" do
record = double("record", id: 1, as_json: { "id" => 1, "error" => "boom" })
allow(record).to receive(:update).and_return(true)
resource = build_resource(model: model_finding(record), adapter: permit_all, permitted: %i[error])

result = update(resource, attributes: { "error" => "boom" })

expect(record).to have_received(:update).with(error: "boom")
expect(result[:updated]).to eq([:error])
end
end
Loading
Loading