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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/activeadmin_mcp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
149 changes: 149 additions & 0 deletions lib/activeadmin_mcp/form_description.rb
Original file line number Diff line number Diff line change
@@ -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
101 changes: 101 additions & 0 deletions lib/activeadmin_mcp/form_field_collector.rb
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions lib/activeadmin_mcp/request_handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 " \
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading