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