From f9d79a2e4e4aed0230249ee78f3d94c06df7e783 Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Fri, 14 Aug 2026 16:10:25 +0100 Subject: [PATCH] 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) --- README.md | 19 +++ .../active_admin_mcp/mcp_controller.rb | 2 +- lib/active_admin_mcp.rb | 1 + lib/active_admin_mcp/record_updater.rb | 87 ++++++++++++ lib/active_admin_mcp/request_handler.rb | 31 +++++ lib/active_admin_mcp/resource_registry.rb | 2 +- spec/active_admin_mcp/record_updater_spec.rb | 131 ++++++++++++++++++ spec/active_admin_mcp/request_handler_spec.rb | 59 +++++++- .../resource_registry_spec.rb | 10 +- 9 files changed, 336 insertions(+), 6 deletions(-) create mode 100644 lib/active_admin_mcp/record_updater.rb create mode 100644 spec/active_admin_mcp/record_updater_spec.rb diff --git a/README.md b/README.md index 223c125..f3fea9b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/app/controllers/active_admin_mcp/mcp_controller.rb b/app/controllers/active_admin_mcp/mcp_controller.rb index 44451b6..f7d8d71 100644 --- a/app/controllers/active_admin_mcp/mcp_controller.rb +++ b/app/controllers/active_admin_mcp/mcp_controller.rb @@ -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 diff --git a/lib/active_admin_mcp.rb b/lib/active_admin_mcp.rb index 590d735..522d8a6 100644 --- a/lib/active_admin_mcp.rb +++ b/lib/active_admin_mcp.rb @@ -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" diff --git a/lib/active_admin_mcp/record_updater.rb b/lib/active_admin_mcp/record_updater.rb new file mode 100644 index 0000000..6138a8c --- /dev/null +++ b/lib/active_admin_mcp/record_updater.rb @@ -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 diff --git a/lib/active_admin_mcp/request_handler.rb b/lib/active_admin_mcp/request_handler.rb index fa5b6d9..204165b 100644 --- a/lib/active_admin_mcp/request_handler.rb +++ b/lib/active_admin_mcp/request_handler.rb @@ -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"] @@ -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 @@ -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 @@ -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 diff --git a/lib/active_admin_mcp/resource_registry.rb b/lib/active_admin_mcp/resource_registry.rb index 1c47ad7..f27f2bb 100644 --- a/lib/active_admin_mcp/resource_registry.rb +++ b/lib/active_admin_mcp/resource_registry.rb @@ -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 diff --git a/spec/active_admin_mcp/record_updater_spec.rb b/spec/active_admin_mcp/record_updater_spec.rb new file mode 100644 index 0000000..ff5af8b --- /dev/null +++ b/spec/active_admin_mcp/record_updater_spec.rb @@ -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 diff --git a/spec/active_admin_mcp/request_handler_spec.rb b/spec/active_admin_mcp/request_handler_spec.rb index 41a8bc5..a3df239 100644 --- a/spec/active_admin_mcp/request_handler_spec.rb +++ b/spec/active_admin_mcp/request_handler_spec.rb @@ -42,10 +42,10 @@ def handle(method, params = nil, id: 1) end describe "tools/list" do - it "advertises the list_resources and query tools" do + it "advertises the list_resources, query and update tools" do tools = handle("tools/list")[:result][:tools] - expect(tools.map { |t| t[:name] }).to contain_exactly("list_resources", "query") + expect(tools.map { |t| t[:name] }).to contain_exactly("list_resources", "query", "update") end it "marks resource as required on the query tool" do @@ -54,6 +54,13 @@ def handle(method, params = nil, id: 1) expect(query[:inputSchema][:required]).to eq(["resource"]) end + + it "requires resource, id and attributes on the update tool" do + tools = handle("tools/list")[:result][:tools] + update = tools.find { |t| t[:name] == "update" } + + expect(update[:inputSchema][:required]).to contain_exactly("resource", "id", "attributes") + end end describe "unknown method" do @@ -126,6 +133,54 @@ def call_tool(name, arguments = {}) end end + describe "update" do + it "returns an error when the resource is not found" do + allow(ActiveAdminMcp::ResourceRegistry).to receive(:find).with("Ghost").and_return(nil) + + expect(call_tool("update", "resource" => "Ghost", "id" => 1, "attributes" => { "name" => "x" })) + .to eq("error" => "Resource not found: Ghost") + end + + it "returns an error when no id is given" do + allow(ActiveAdminMcp::ResourceRegistry).to receive(:find) + .with("User").and_return(name: "User", model: double, config: double) + + expect(call_tool("update", "resource" => "User", "attributes" => { "name" => "x" })) + .to eq("error" => "id is required") + end + + it "returns an error when no attributes are given" do + allow(ActiveAdminMcp::ResourceRegistry).to receive(:find) + .with("User").and_return(name: "User", model: double, config: double) + + expect(call_tool("update", "resource" => "User", "id" => 1)) + .to eq("error" => "attributes are required") + end + + it "delegates to the record updater with the resource and current user" do + resource = { name: "User", model: double, config: double } + allow(ActiveAdminMcp::ResourceRegistry).to receive(:find).with("User").and_return(resource) + updater = instance_double(ActiveAdminMcp::RecordUpdater, call: { updated: [:name] }) + allow(ActiveAdminMcp::RecordUpdater).to receive(:new).and_return(updater) + + handler = described_class.new(current_user: :admin) + response = handler.handle( + "id" => 1, + "method" => "tools/call", + "params" => { + "name" => "update", + "arguments" => { "resource" => "User", "id" => 7, "attributes" => { "name" => "x" } }, + }, + ) + result = JSON.parse(response[:result][:content].first[:text]) + + expect(ActiveAdminMcp::RecordUpdater).to have_received(:new) + .with(resource: resource, current_user: :admin) + expect(updater).to have_received(:call).with(id: 7, attributes: { "name" => "x" }) + expect(result).to eq("updated" => ["name"]) + end + end + describe "an unknown tool" do it "returns an error naming the tool" do expect(call_tool("frobnicate")).to eq("error" => "Unknown tool: frobnicate") diff --git a/spec/active_admin_mcp/resource_registry_spec.rb b/spec/active_admin_mcp/resource_registry_spec.rb index f606021..a3eae38 100644 --- a/spec/active_admin_mcp/resource_registry_spec.rb +++ b/spec/active_admin_mcp/resource_registry_spec.rb @@ -82,11 +82,17 @@ def stub_active_admin(models) end describe ".find" do - it "returns the name and model class for a known resource" do + it "returns the name, model class and resource config for a known resource" do user = build_model(name: "User") stub_active_admin([user]) - expect(described_class.find("User")).to eq(name: "User", model: user) + result = described_class.find("User") + + expect(result[:name]).to eq("User") + expect(result[:model]).to eq(user) + # The config carries the ActiveAdmin resource so callers can inspect + # actions/authorization (used by the update tool). + expect(result[:config].resource_class).to eq(user) end it "returns nil for an unknown resource" do