From ae6f6a05329ad43d7b6b3d8a86f3da459c4f22b3 Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Sat, 19 Sep 2026 10:43:48 +0100 Subject: [PATCH] Describe ActiveAdmin forms over MCP with a describe_form tool Phase 3 of the actions-and-forms design (#8), and the last of them. A client calling create or update had to guess field types and allowed values from column names. describe_form reads the resource's own `form do ... end` block when it declares one, reporting each input's as:, label:, hint: and, when the collection: is a literal array, its allowed values. Most resources declare no form block. ActiveAdmin renders a bare `f.inputs` for those, which Formtastic only expands at render time, so there is nothing to introspect; the description is derived from permit_params instead, which is what create and update enforce anyway. The payload says which source it used. Either way each field is annotated from the model with its column type and whether the model validates its presence. FormFieldCollector returns, rebuilt for this rather than for the bare names phase 2 removed the last consumer of. It now records input options and reports has_many blocks as nested groups instead of skipping them. A collection: that is a relation or a proc is omitted rather than evaluated: a description should not fire a query, and a relation can be arbitrarily large. action: selects the gate, not the shape, since ActiveAdmin uses one form block for both. Describing a form the user could never submit discloses the shape of a resource they cannot write, so "new" requires create and "edit" requires update, refused with the messages those tools already use. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 21 +++ README.md | 41 ++++- lib/activeadmin_mcp.rb | 2 + lib/activeadmin_mcp/form_description.rb | 149 ++++++++++++++++++ lib/activeadmin_mcp/form_field_collector.rb | 101 ++++++++++++ lib/activeadmin_mcp/request_handler.rb | 28 ++++ spec/activeadmin_mcp/form_description_spec.rb | 132 ++++++++++++++++ .../form_field_collector_spec.rb | 122 ++++++++++++++ spec/activeadmin_mcp/request_handler_spec.rb | 47 +++++- spec/e2e/fixture_app/app/admin/reviews.rb | 15 ++ spec/e2e/fixture_app/app/models/review.rb | 10 ++ .../migrate/20260101000005_create_reviews.rb | 11 ++ spec/e2e/mcp_server_spec.rb | 4 +- spec/e2e/mcp_tools_spec.rb | 72 +++++++++ spec/support/active_admin.rb | 32 ++++ 15 files changed, 782 insertions(+), 5 deletions(-) create mode 100644 lib/activeadmin_mcp/form_description.rb create mode 100644 lib/activeadmin_mcp/form_field_collector.rb create mode 100644 spec/activeadmin_mcp/form_description_spec.rb create mode 100644 spec/activeadmin_mcp/form_field_collector_spec.rb create mode 100644 spec/e2e/fixture_app/app/admin/reviews.rb create mode 100644 spec/e2e/fixture_app/app/models/review.rb create mode 100644 spec/e2e/fixture_app/db/migrate/20260101000005_create_reviews.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 9db7adf..8605743 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- A `describe_form` tool, which describes the fields behind a resource's create + or update form so a client need not guess them from column names. It reads + the resource's own `form do ... end` block when it declares one — reporting + each input's `as:`, `label:`, `hint:`, and its allowed values when the + `collection:` is a literal array — and otherwise derives the description from + the resource's `permit_params`, which is what `create` and `update` enforce + anyway. The response says which of the two it used. Every field is annotated + from the model with its column type and whether the model validates its + presence, and `has_many` groups are reported as nested rather than flattened + into the record's own fields. + + `action:` selects the gate rather than the shape, since ActiveAdmin uses one + form block for both: `"new"` requires the resource to register `create` and + pass `create` authorization, `"edit"` requires `update`. A form the user + could never submit is refused with the same messages `create` and `update` + give. + + A `collection:` that is a relation or a proc is omitted rather than + evaluated: describing a form should not fire a query, and a relation can be + arbitrarily large. + - A `create` tool, which creates a record by dispatching the resource's own ActiveAdmin `create` action. Resources registered without that action are refused, `permit_params` decides what may be written, the namespace's diff --git a/README.md b/README.md index 5f8e47a..07ca5f6 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ The server is a Rails engine mounted inside your application (by default at `before_save`/`after_update` callbacks, the controller's `before_action` chain and your authorization adapter all apply exactly as they do when someone clicks Save in the admin UI. Resources that don't register the - action are refused. + action are refused, and `describe_form` will tell a client what a given + resource's form accepts before it tries. - **Authentication is optional but built in.** Enable Bearer-token auth and the installer adds an "MCP Tokens" management page to your ActiveAdmin panel. @@ -73,6 +74,7 @@ read/query setup without authentication. | `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). | | `create` | Create a new record through the resource's ActiveAdmin create action, honouring its permitted params, callbacks and authorization. | | `update` | Update an existing record through the resource's ActiveAdmin update action, honouring its permitted params, callbacks and authorization. | +| `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. | | *(per action)* | Any ActiveAdmin member, collection or batch action the application has opted in with an `mcp:` option, exposed as its own tool. | ### Query examples @@ -116,6 +118,43 @@ action, so a write from MCP is the same write the admin UI makes: A write rejected by the model comes back as a `Validation failed` error with the model's own messages in `details`, and nothing is written. +### Describing a form + +``` +What can I set when creating a post? +→ describe_form(resource: "Post") +→ describe_form(resource: "Post", action: "edit") +``` + +`describe_form` reads the resource's own `form do ... end` block when it +declares one, reporting each input's `as:`, `label:`, `hint:` and — when the +`collection:` is a literal array — its allowed values. Resources that declare +no form block get a description derived from their `permit_params` instead: +ActiveAdmin renders a bare `f.inputs` for those, which Formtastic only expands +at render time, so there is nothing to read. The response's `source` says which +of the two you are looking at. + +Either way every field is annotated from the model with the column type it is +stored in and whether the model validates its presence. `has_many` blocks are +reported under `nested` rather than flattened in with the record's own fields. + +`action:` selects the gate, not the shape — ActiveAdmin uses one form block for +both. `"new"` (the default) requires the resource to register `create` and pass +`create` authorization; `"edit"` requires `update`. Describing a form you could +never submit tells you nothing you can act on, so it is refused with the same +messages `create` and `update` use. + +Two limits worth knowing: + +- A `collection:` that is an `ActiveRecord::Relation` or a proc is omitted + rather than evaluated. Describing a form should not fire a query, and a + relation can be arbitrarily large. Use an action's + [`suggestions:`](#running-member-collection-and-batch-actions) when you want + dynamic values. +- A field a form block declares but `permit_params` omits is described and then + silently dropped on write. This cannot arise on the `permit_params` fallback + path. + ### Running member, collection and batch actions ActiveAdmin actions are **not** exposed by default. An action becomes an MCP diff --git a/lib/activeadmin_mcp.rb b/lib/activeadmin_mcp.rb index 2bfe067..7ebcf86 100644 --- a/lib/activeadmin_mcp.rb +++ b/lib/activeadmin_mcp.rb @@ -9,6 +9,8 @@ require_relative "activeadmin_mcp/controller_dispatcher" require_relative "activeadmin_mcp/action_runner" require_relative "activeadmin_mcp/resource_registry" +require_relative "activeadmin_mcp/form_field_collector" +require_relative "activeadmin_mcp/form_description" require_relative "activeadmin_mcp/record_writer" require_relative "activeadmin_mcp/request_handler" require_relative "activeadmin_mcp/engine" diff --git a/lib/activeadmin_mcp/form_description.rb b/lib/activeadmin_mcp/form_description.rb new file mode 100644 index 0000000..4ecee09 --- /dev/null +++ b/lib/activeadmin_mcp/form_description.rb @@ -0,0 +1,149 @@ +module ActiveadminMcp + # Describes the form behind a resource's create or update action richly + # enough that an MCP client stops guessing field types and allowed values + # from column names. + # + # The description is read from the resource's own `form do ... end` block + # when it declares one. When it does not, ActiveAdmin renders a bare + # `f.inputs` that Formtastic only expands at render time — there is nothing + # to introspect — so the description is derived from the resource's + # `permit_params` instead, which is what `create` and `update` enforce + # anyway. The payload says which of the two it is. + # + # Either way each field is annotated from the model: the column type it is + # stored in, and whether the model validates its presence. + class FormDescription + WRITE_ACTIONS = { "new" => :create, "edit" => :update }.freeze + REFUSALS = { create: "is not creatable", update: "is not editable" }.freeze + + def initialize(resource:, current_user:) + @resource = resource + @current_user = current_user + @config = resource[:config] + end + + def call(action: "new") + write_action = WRITE_ACTIONS[action.to_s] + return error(%(Unknown form action: #{action} (expected "new" or "edit"))) unless write_action + + refusal = write_refusal(write_action) + return refusal if refusal + + declared = declared_inputs + return describe(action, "form", declared) if declared + + permitted = permitted_inputs + return permit_params_refusal unless permitted + + describe(action, "permit_params", permitted) + end + + private + + def describe(action, source, inputs) + fields, groups = inputs.partition { |input| !input.key?(:nested) } + + { + resource: @resource[:name], + action: action.to_s, + source: source, + attributes: fields.map { |field| attribute(field) }, + nested: groups.map { |group| nested_group(group) }, + } + end + + # An association's own fields are reported as the form declared them and + # are not annotated from a model: they belong to the associated record, not + # to the one being written. + def nested_group(group) + { name: group[:name].to_s, attributes: group[:nested].map { |field| stringify(field) } } + end + + def attribute(field) + described = stringify(field) + described[:type] = column_type(field[:name]) if column_type(field[:name]) + described[:required] = true if !described.key?(:required) && presence_validated?(field[:name]) + described + end + + def stringify(field) + field.each_with_object({}) do |(key, value), described| + described[key] = value.is_a?(Symbol) ? value.to_s : value + end + end + + def column_type(name) + @resource[:model].columns_hash[name.to_s]&.type&.to_s + end + + def presence_validated?(name) + @resource[:model].validators_on(name).any? do |validator| + validator.is_a?(ActiveModel::Validations::PresenceValidator) + end + rescue StandardError + false + end + + def declared_inputs + block = form_block + return nil unless block + + FormFieldCollector.new.collect(&block) + rescue StandardError => e + # A form block that will not run outside a request describes nothing, but + # the resource's permitted params still can. + warn("[activeadmin_mcp] reading the #{@resource[:name]} form raised #{e.class}: #{e.message}") + nil + end + + def form_block + return nil unless @config.respond_to?(:page_presenters) + + @config.page_presenters[:form]&.block + end + + # ActiveAdmin stores no list of the params it permits, only a method that + # filters against them, so the permitted names are recovered by offering it + # every column the model has and seeing which survive. A resource that + # never declared permit_params has no such method to answer, and is + # reported as unwritable rather than described. + def permitted_inputs + names = permitted_names + return nil unless names + + names.map { |name| { name: name } } + end + + def permitted_names + param_key = @config.param_key.to_sym + controller = @config.controller.new + controller.params = ActionController::Parameters.new( + param_key => @resource[:model].column_names.index_with { nil } + ) + permitted = controller.send(:permitted_params) + scoped = permitted && permitted[param_key] + scoped&.keys&.map(&:to_sym) + rescue StandardError + nil + end + + def write_refusal(write_action) + unless @config.defined_actions.include?(write_action) + return error("Resource #{REFUSALS[write_action]}: #{@resource[:name]}") + end + + return if Authorization.for(@config, @current_user) + .authorized?(write_action, @config.resource_class) + + error("Not authorized to #{write_action} #{@resource[:name]}") + end + + def permit_params_refusal + error("Resource declares no permit_params, so nothing may be written: #{@resource[:name]}") + end + + def error(message) + { error: message } + end + end +end diff --git a/lib/activeadmin_mcp/form_field_collector.rb b/lib/activeadmin_mcp/form_field_collector.rb new file mode 100644 index 0000000..8f7567f --- /dev/null +++ b/lib/activeadmin_mcp/form_field_collector.rb @@ -0,0 +1,101 @@ +module ActiveadminMcp + # Reads an ActiveAdmin `form do ... end` block and reports what each input + # tells a client about how to fill the field in. + # + # ActiveAdmin form blocks are arbitrary Formtastic DSL — `input`, `inputs`, + # `actions`, application helpers, conditionals — so the block is run against + # this stand-in form builder rather than parsed. Every `input :field` is + # recorded; every other message (including helpers that would need a view + # context we do not have) is swallowed and returns self, so the block runs to + # completion outside a request. + # + # Only the options that describe the field to whoever is filling it in are + # kept. Presentation options (`input_html:`, `wrapper_html:` and the rest) + # say nothing an MCP client can act on and are dropped. + class FormFieldCollector + DESCRIBED_TEXT_OPTIONS = %i[label hint].freeze + + def initialize + @inputs = [] + end + + def collect(&block) + instance_exec(self, &block) + @inputs + end + + def input(name, *_args, **options, &_block) + return self unless name.respond_to?(:to_sym) + return self if declared?(name.to_sym) + + @inputs << describe(name.to_sym, options) + self + end + + def inputs(*_args, **_opts, &block) + instance_exec(self, &block) if block + self + end + + # Recorded as a group of its own rather than descended into, so a client + # can tell an association's fields from the record's own. Flattening them + # would advertise `body` as an attribute of the parent record. + def has_many(name, *_args, **_opts, &block) + return self unless name.respond_to?(:to_sym) + return self if declared?(name.to_sym) + + @inputs << { name: name.to_sym, nested: block ? self.class.new.collect(&block) : [] } + self + end + + def method_missing(_name, *_args, **_opts, &block) + instance_exec(self, &block) if block + self + end + + def respond_to_missing?(_name, _include_private = false) + true + end + + private + + def declared?(name) + @inputs.any? { |input| input[:name] == name } + end + + def describe(name, options) + described = { name: name } + + described[:as] = options[:as].to_sym if scalar_name?(options[:as]) + described[:required] = options[:required] if [true, false].include?(options[:required]) + + DESCRIBED_TEXT_OPTIONS.each do |key| + described[key] = options[key].to_s if scalar_name?(options[key]) + end + + values = allowed_values(options[:collection]) + described[:collection] = values if values + + described + end + + # A helper the collector swallowed comes back as the collector itself, so + # anything that is not plain text is not something to report as a label. + def scalar_name?(value) + value.is_a?(String) || value.is_a?(Symbol) + end + + # Only a literal array is resolved. A relation would mean firing a query + # from what is meant to be a description, and can be arbitrarily large; a + # proc usually needs the view context this collector does not have. Either + # is omitted rather than evaluated. + def allowed_values(collection) + return nil unless collection.is_a?(Array) + + values = collection.map { |entry| entry.is_a?(Array) ? entry.last : entry } + return nil unless values.all? { |value| scalar_name?(value) || value.is_a?(Numeric) } + + values + end + end +end diff --git a/lib/activeadmin_mcp/request_handler.rb b/lib/activeadmin_mcp/request_handler.rb index c52e81f..d44a6db 100644 --- a/lib/activeadmin_mcp/request_handler.rb +++ b/lib/activeadmin_mcp/request_handler.rb @@ -114,6 +114,25 @@ def built_in_tools required: ["resource"], }, }, + { + name: "describe_form", + description: "Describe the fields of a resource's ActiveAdmin form — their input " \ + "types, labels, hints, allowed values, column types and which are " \ + "required — so a create or update call need not guess them.", + inputSchema: { + type: "object", + properties: { + resource: { type: "string", description: "Resource name (e.g., 'User', 'Post')" }, + action: { + type: "string", + enum: %w[new edit], + description: "Which form to describe: 'new' for creating, 'edit' for updating " \ + "(default: 'new')", + }, + }, + required: ["resource"], + }, + }, { name: "create", description: "Create a new record. The write runs through the resource's real " \ @@ -153,6 +172,7 @@ def call_tool(params) result = case name when "list_resources" then tool_list_resources when "query" then tool_query(args) + when "describe_form" then tool_describe_form(args) when "create" then tool_create(args) when "update" then tool_update(args) else tool_action(name, args) @@ -186,6 +206,14 @@ def tool_query(args) { resource: resource[:name], count: records.size, records: filter_sensitive(records.as_json) } end + def tool_describe_form(args) + resource = ResourceRegistry.find(args["resource"]) + return { error: "Resource not found: #{args['resource']}" } unless resource + + FormDescription.new(resource: resource, current_user: @current_user) + .call(action: args["action"] || "new") + end + def tool_create(args) resource = ResourceRegistry.find(args["resource"]) return { error: "Resource not found: #{args['resource']}" } unless resource diff --git a/spec/activeadmin_mcp/form_description_spec.rb b/spec/activeadmin_mcp/form_description_spec.rb new file mode 100644 index 0000000..fbdd800 --- /dev/null +++ b/spec/activeadmin_mcp/form_description_spec.rb @@ -0,0 +1,132 @@ +require "spec_helper" +require "support/active_admin" + +RSpec.describe ActiveadminMcp::FormDescription do + let(:admin) { AdminUser.create!(email: "admin@example.com") } + + after { AdminUser.delete_all } + + def describe_form(resource_name, action: "new") + resource = ActiveadminMcp::ResourceRegistry.find(resource_name) + described_class.new(resource: resource, current_user: admin).call(action: action) + end + + def attribute(result, name) + result[:attributes].find { |attribute| attribute[:name] == name } + end + + describe "a resource that declares its own form block" do + it "says the description was read from the resource's declared form" do + expect(describe_form("Shift")[:source]).to eq("form") + end + + it "lists the fields the form declares, in the order the form declares them" do + expect(describe_form("Shift")[:attributes].map { |attribute| attribute[:name] }).to eq( + %w[name location starts_at] + ) + end + + it "carries the hint, label and input type the form declared for each field" do + result = describe_form("Shift") + + expect(attribute(result, "name")[:hint]).to eq("How the shift appears on the rota") + expect(attribute(result, "starts_at")[:label]).to eq("Starts") + expect(attribute(result, "starts_at")[:as]).to eq("datetime_select") + end + + it "carries the allowed values of a select declared with a literal collection" do + expect(attribute(describe_form("Shift"), "location")[:collection]).to eq(%w[kitchen warehouse]) + end + + it "annotates each field with the column type the model stores it in" do + result = describe_form("Shift") + + expect(attribute(result, "name")[:type]).to eq("string") + expect(attribute(result, "starts_at")[:type]).to eq("datetime") + end + + # Flattening them would advertise an association's fields as attributes of + # the record itself, which is not what a client could write. + it "reports a has_many group as nested rather than as fields of the record itself" do + result = describe_form("Shift") + + expect(result[:attributes].map { |attribute| attribute[:name] }).not_to include("sightings") + expect(result[:nested]).to eq([{ name: "sightings", attributes: [{ name: "species" }] }]) + end + + it "marks a field required when the model validates its presence" do + result = describe_form("Shift") + + expect(attribute(result, "name")[:required]).to be(true) + expect(attribute(result, "location")).not_to have_key(:required) + end + end + + describe "a resource that declares no form block" do + it "says the description was derived from the resource's permitted params" do + expect(describe_form("Volunteer")[:source]).to eq("permit_params") + end + + it "lists the fields the resource's permit_params accepts, and no others" do + expect(describe_form("Volunteer")[:attributes].map { |attribute| attribute[:name] }).to eq( + %w[name active] + ) + end + + it "annotates the derived fields with column types and presence validators just the same" do + result = describe_form("Volunteer") + + expect(attribute(result, "active")[:type]).to eq("boolean") + expect(attribute(result, "name")[:required]).to be(true) + end + + it "refuses a resource that declares no permit_params either, because nothing may be written" do + expect(describe_form("Sighting")[:error]).to match(/permit_params/) + end + end + + describe "choosing which form to describe" do + it "describes the new form by default" do + expect(describe_form("Shift")[:action]).to eq("new") + end + + it "describes the edit form when asked for it" do + expect(describe_form("Shift", action: "edit")[:action]).to eq("edit") + end + + it "refuses an action that is neither new nor edit" do + expect(describe_form("Shift", action: "destroy")[:error]).to match(/new.*edit/) + end + end + + # The form is the interface to a write, so describing one the user could + # never submit tells them nothing they can act on and discloses the shape of + # a resource they cannot write. + describe "authorization" do + it "refuses the new form of a resource registered without the create action" do + expect(describe_form("Note")[:error]).to eq("Resource is not creatable: Note") + end + + it "refuses the edit form of a resource registered without the update action" do + expect(describe_form("Note", action: "edit")[:error]).to eq("Resource is not editable: Note") + end + + context "when the authorization adapter denies everything" do + around do |example| + namespace = ActiveAdmin.application.namespaces[:admin] + previous = namespace.authorization_adapter + namespace.authorization_adapter = DenyingAuthorizationAdapter + example.run + namespace.authorization_adapter = previous + end + + it "refuses to describe the new form" do + expect(describe_form("Shift")[:error]).to match(/not authorized to create/i) + end + + it "refuses to describe the edit form" do + expect(describe_form("Shift", action: "edit")[:error]).to match(/not authorized to update/i) + end + end + end +end diff --git a/spec/activeadmin_mcp/form_field_collector_spec.rb b/spec/activeadmin_mcp/form_field_collector_spec.rb new file mode 100644 index 0000000..d175e7a --- /dev/null +++ b/spec/activeadmin_mcp/form_field_collector_spec.rb @@ -0,0 +1,122 @@ +require "spec_helper" + +RSpec.describe ActiveadminMcp::FormFieldCollector do + def collect(&block) + described_class.new.collect(&block) + end + + it "records a bare input as a field with no declared options" do + inputs = collect { |_f| input :title } + + expect(inputs).to eq([{ name: :title }]) + end + + it "records the input options a client needs in order to stop guessing" do + inputs = collect do |_f| + input :status, as: :select, required: true, label: "Publication status", hint: "Draft until reviewed" + end + + expect(inputs.first).to include( + name: :status, + as: :select, + required: true, + label: "Publication status", + hint: "Draft until reviewed" + ) + end + + it "ignores input options that say nothing about how to fill the field in" do + inputs = collect { |_f| input :title, wrapper_html: { class: "wide" }, input_html: { size: 40 } } + + expect(inputs).to eq([{ name: :title }]) + end + + it "descends into inputs blocks, which is where most forms declare their fields" do + inputs = collect do |_f| + inputs "Details" do + input :title + input :body + end + actions + end + + expect(inputs.map { |input| input[:name] }).to eq(%i[title body]) + end + + it "tolerates helper calls and conditionals that need a view context the collector does not have" do + inputs = collect do |_f| + semantic_errors + inputs do + input :title, hint: some_undefined_helper + input :author_id, as: :hidden + end + actions + end + + expect(inputs.map { |input| input[:name] }).to eq(%i[title author_id]) + expect(inputs.first).not_to have_key(:hint) + end + + it "records a has_many block as a nested group rather than flattening its fields into the parent" do + inputs = collect do |_f| + input :title + has_many :comments do |c| + c.input :body + end + end + + expect(inputs).to eq( + [ + { name: :title }, + { name: :comments, nested: [{ name: :body }] }, + ] + ) + end + + describe "a collection: of allowed values" do + it "records a literal array of values" do + inputs = collect { |_f| input :status, as: :select, collection: %w[draft published] } + + expect(inputs.first[:collection]).to eq(%w[draft published]) + end + + it "records the values of label/value pairs, which is how a select names them separately" do + inputs = collect { |_f| input :status, as: :select, collection: [["Draft", "draft"], ["Published", "published"]] } + + expect(inputs.first[:collection]).to eq(%w[draft published]) + end + + # A relation would mean firing a query from a description call, and can be + # arbitrarily large; a proc usually needs the view context we do not have. + it "omits a collection that is not a literal array, rather than evaluating it" do + queried = false + relation = Class.new do + define_method(:to_a) { queried = true } + end.new + + inputs = collect { |_f| input :author, as: :select, collection: relation } + + expect(inputs.first).not_to have_key(:collection) + expect(queried).to be(false) + end + + it "omits a collection whose entries are neither scalars nor pairs" do + inputs = collect { |_f| input :author, as: :select, collection: [Object.new] } + + expect(inputs.first).not_to have_key(:collection) + end + end + + it "records each field once when a form declares the same input twice" do + inputs = collect do |_f| + input :title + input :title, hint: "Second thoughts" + end + + expect(inputs.map { |input| input[:name] }).to eq([:title]) + end + + it "returns nothing at all for a form block that declares no inputs" do + expect(collect { |_f| actions }).to eq([]) + end +end diff --git a/spec/activeadmin_mcp/request_handler_spec.rb b/spec/activeadmin_mcp/request_handler_spec.rb index 238533e..9ca612c 100644 --- a/spec/activeadmin_mcp/request_handler_spec.rb +++ b/spec/activeadmin_mcp/request_handler_spec.rb @@ -58,10 +58,11 @@ def handle(method, params = nil, id: 1) describe "tools/list" do before { allow(ActiveadminMcp::ActionCatalog).to receive(:all).and_return([]) } - it "advertises the list_resources, query, create and update tools" do + it "advertises the list_resources, query, create, update and describe_form tools" do tools = handle("tools/list")[:result][:tools] - expect(tools.map { |t| t[:name] }).to contain_exactly("list_resources", "query", "create", "update") + expect(tools.map { |t| t[:name] }) + .to contain_exactly("list_resources", "query", "create", "update", "describe_form") end it "marks resource as required on the query tool" do @@ -78,6 +79,14 @@ def handle(method, params = nil, id: 1) expect(update[:inputSchema][:required]).to contain_exactly("resource", "id", "attributes") end + it "requires only resource on the describe_form tool, since the action defaults" do + tools = handle("tools/list")[:result][:tools] + describe_form = tools.find { |t| t[:name] == "describe_form" } + + expect(describe_form[:inputSchema][:required]).to eq(["resource"]) + expect(describe_form[:inputSchema][:properties][:action][:enum]).to eq(%w[new edit]) + end + it "requires resource and attributes on the create tool" do tools = handle("tools/list")[:result][:tools] create = tools.find { |t| t[:name] == "create" } @@ -274,6 +283,40 @@ def stub_resource(authorized: true) end end + describe "describe_form" 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("describe_form", "resource" => "Ghost")) + .to eq("error" => "Resource not found: Ghost") + end + + it "delegates to the form description 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) + description = instance_double(ActiveadminMcp::FormDescription, call: { source: "form" }) + allow(ActiveadminMcp::FormDescription).to receive(:new).and_return(description) + + result = call_tool_as(:admin, "describe_form", "resource" => "User", "action" => "edit") + + expect(ActiveadminMcp::FormDescription).to have_received(:new) + .with(resource: resource, current_user: :admin) + expect(description).to have_received(:call).with(action: "edit") + expect(result).to eq("source" => "form") + end + + it "describes the new form when no action is given" do + resource = { name: "User", model: double, config: double } + allow(ActiveadminMcp::ResourceRegistry).to receive(:find).with("User").and_return(resource) + description = instance_double(ActiveadminMcp::FormDescription, call: {}) + allow(ActiveadminMcp::FormDescription).to receive(:new).and_return(description) + + call_tool("describe_form", "resource" => "User") + + expect(description).to have_received(:call).with(action: "new") + 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/e2e/fixture_app/app/admin/reviews.rb b/spec/e2e/fixture_app/app/admin/reviews.rb new file mode 100644 index 0000000..5244b88 --- /dev/null +++ b/spec/e2e/fixture_app/app/admin/reviews.rb @@ -0,0 +1,15 @@ +# The only fixture resource that declares an explicit `form do ... end` block, +# so the e2e suite can prove `describe_form` reads a declared form — its input +# types, labels, hints and literal collections — rather than deriving the +# description from permitted params as it does for every other resource here. +ActiveAdmin.register Review do + permit_params :body, :status + + form do |f| + f.inputs "Review" do + f.input :body, hint: "Shown beneath the post" + f.input :status, as: :select, collection: %w[pending approved], label: "Moderation status" + end + f.actions + end +end diff --git a/spec/e2e/fixture_app/app/models/review.rb b/spec/e2e/fixture_app/app/models/review.rb new file mode 100644 index 0000000..677460a --- /dev/null +++ b/spec/e2e/fixture_app/app/models/review.rb @@ -0,0 +1,10 @@ +class Review < ApplicationRecord + belongs_to :post, optional: true + + validates :body, presence: true + + # See the note in post.rb: Ransack 4 requires an explicit allowlist. + def self.ransackable_attributes(_auth_object = nil) + column_names + end +end diff --git a/spec/e2e/fixture_app/db/migrate/20260101000005_create_reviews.rb b/spec/e2e/fixture_app/db/migrate/20260101000005_create_reviews.rb new file mode 100644 index 0000000..a1286fa --- /dev/null +++ b/spec/e2e/fixture_app/db/migrate/20260101000005_create_reviews.rb @@ -0,0 +1,11 @@ +class CreateReviews < ActiveRecord::Migration[7.2] + def change + create_table :reviews do |t| + t.references :post + t.text :body + t.string :status, default: "pending", null: false + + t.timestamps + end + end +end diff --git a/spec/e2e/mcp_server_spec.rb b/spec/e2e/mcp_server_spec.rb index 7bf3d5b..e6b0cbc 100644 --- a/spec/e2e/mcp_server_spec.rb +++ b/spec/e2e/mcp_server_spec.rb @@ -12,10 +12,10 @@ expect(result["capabilities"]).to have_key("tools") end - it "advertises the four built-in tools, alongside whatever the fixture app has opted in to MCP" do + it "advertises the five built-in tools, alongside whatever the fixture app has opted in to MCP" do names = client.tools_list["tools"].map { |tool| tool["name"] } - expect(names).to include("list_resources", "query", "create", "update") + expect(names).to include("list_resources", "query", "create", "update", "describe_form") end end diff --git a/spec/e2e/mcp_tools_spec.rb b/spec/e2e/mcp_tools_spec.rb index d8e2a3e..23735a0 100644 --- a/spec/e2e/mcp_tools_spec.rb +++ b/spec/e2e/mcp_tools_spec.rb @@ -58,6 +58,78 @@ end end + describe "describe_form" do + def attribute(result, name) + result["attributes"].find { |attribute| attribute["name"] == name } + end + + context "for a resource that declares its own ActiveAdmin form block" do + let(:result) { client.call_tool("describe_form", resource: "Review") } + + it "reads the description from the declared form, and says that is where it came from" do + expect(result["source"]).to eq("form") + expect(result["attributes"].map { |attribute| attribute["name"] }).to eq(%w[body status]) + end + + it "carries the hint, label and input type the form declared for each field" do + expect(attribute(result, "body")["hint"]).to eq("Shown beneath the post") + expect(attribute(result, "status")["label"]).to eq("Moderation status") + expect(attribute(result, "status")["as"]).to eq("select") + end + + it "carries the allowed values of a select declared with a literal collection" do + expect(attribute(result, "status")["collection"]).to eq(%w[pending approved]) + end + + it "annotates each field with the column type it is stored in and the model's presence validation" do + expect(attribute(result, "body")).to include("type" => "text", "required" => true) + expect(attribute(result, "status")["type"]).to eq("string") + end + end + + context "for a resource that declares no form block" do + let(:result) { client.call_tool("describe_form", resource: "Post") } + + it "derives the description from the resource's permitted params, and says so" do + expect(result["source"]).to eq("permit_params") + expect(result["attributes"].map { |attribute| attribute["name"] }).to eq(%w[title body]) + end + + it "annotates the derived fields from the model just as it does a declared form's" do + expect(attribute(result, "title")).to include("type" => "string", "required" => true) + end + + it "describes exactly the fields the create tool will accept, and no others" do + expect(result["attributes"].map { |attribute| attribute["name"] }).not_to include("slug", "status") + end + end + + it "describes the edit form when asked for it" do + result = client.call_tool("describe_form", resource: "Post", action: "edit") + + expect(result["action"]).to eq("edit") + expect(result["error"]).to be_nil + end + + it "refuses the new form of a resource registered without the create action" do + result = client.call_tool("describe_form", resource: "Author") + + expect(result["error"]).to eq("Resource is not creatable: Author") + end + + it "refuses to describe a resource that declares no permitted params, since nothing may be written" do + result = client.call_tool("describe_form", resource: "Tag") + + expect(result["error"]).to match(/permit_params/) + end + + it "reports an unregistered resource rather than raising" do + result = client.call_tool("describe_form", resource: "Nonexistent") + + expect(result["error"]).to eq("Resource not found: Nonexistent") + end + end + describe "create" do def posts client.call_tool("query", resource: "Post")["records"] diff --git a/spec/support/active_admin.rb b/spec/support/active_admin.rb index 08ad1d1..3937084 100644 --- a/spec/support/active_admin.rb +++ b/spec/support/active_admin.rb @@ -33,6 +33,17 @@ # is refused for a resource that never declared what may be written. create_table :sightings, force: true do |t| t.string :species + t.references :shift + t.timestamps + end + + # Registered with an explicit `form do ... end` block, so a spec can prove a + # description is read from the form a resource declares rather than derived + # from its permitted params. + create_table :shifts, force: true do |t| + t.string :name + t.string :location + t.datetime :starts_at t.timestamps end @@ -46,6 +57,11 @@ class Volunteer < ActiveRecord::Base validates :name, presence: true end class Note < ActiveRecord::Base; end +class Shift < ActiveRecord::Base + has_many :sightings + accepts_nested_attributes_for :sightings + validates :name, presence: true +end class Sighting < ActiveRecord::Base; end class AdminUser < ActiveRecord::Base; end @@ -117,6 +133,22 @@ class ApplicationController < ActionController::Base; end end end +ActiveAdmin.register Shift do + permit_params :name, :location, :starts_at + + form do |f| + f.inputs "Shift" do + f.input :name, hint: "How the shift appears on the rota" + f.input :location, as: :select, collection: %w[kitchen warehouse] + f.input :starts_at, as: :datetime_select, label: "Starts" + end + f.has_many :sightings do |sighting| + sighting.input :species + end + f.actions + end +end + ActiveAdmin.register Note do actions :index, :show end