activeadmin_mcp 0.0.3 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  module ActiveadminMcp
4
2
  class Engine < ::Rails::Engine
5
3
  isolate_namespace ActiveadminMcp
@@ -16,5 +14,11 @@ module ActiveadminMcp
16
14
  end
17
15
  end
18
16
  end
17
+
18
+ initializer "activeadmin_mcp.active_admin_ext" do
19
+ ActiveSupport.on_load(:after_initialize) do
20
+ ActiveAdmin.after_load { ActiveadminMcp::ActiveAdminExt.apply! } if defined?(::ActiveAdmin)
21
+ end
22
+ end
19
23
  end
20
24
  end
@@ -0,0 +1,149 @@
1
+ module ActiveadminMcp
2
+ # Describes the form behind a resource's create or update action richly
3
+ # enough that an MCP client stops guessing field types and allowed values
4
+ # from column names.
5
+ #
6
+ # The description is read from the resource's own `form do ... end` block
7
+ # when it declares one. When it does not, ActiveAdmin renders a bare
8
+ # `f.inputs` that Formtastic only expands at render time — there is nothing
9
+ # to introspect — so the description is derived from the resource's
10
+ # `permit_params` instead, which is what `create` and `update` enforce
11
+ # anyway. The payload says which of the two it is.
12
+ #
13
+ # Either way each field is annotated from the model: the column type it is
14
+ # stored in, and whether the model validates its presence.
15
+ class FormDescription
16
+ WRITE_ACTIONS = { "new" => :create, "edit" => :update }.freeze
17
+ REFUSALS = { create: "is not creatable", update: "is not editable" }.freeze
18
+
19
+ def initialize(resource:, current_user:)
20
+ @resource = resource
21
+ @current_user = current_user
22
+ @config = resource[:config]
23
+ end
24
+
25
+ def call(action: "new")
26
+ write_action = WRITE_ACTIONS[action.to_s]
27
+ return error(%(Unknown form action: #{action} (expected "new" or "edit"))) unless write_action
28
+
29
+ refusal = write_refusal(write_action)
30
+ return refusal if refusal
31
+
32
+ declared = declared_inputs
33
+ return describe(action, "form", declared) if declared
34
+
35
+ permitted = permitted_inputs
36
+ return permit_params_refusal unless permitted
37
+
38
+ describe(action, "permit_params", permitted)
39
+ end
40
+
41
+ private
42
+
43
+ def describe(action, source, inputs)
44
+ fields, groups = inputs.partition { |input| !input.key?(:nested) }
45
+
46
+ {
47
+ resource: @resource[:name],
48
+ action: action.to_s,
49
+ source: source,
50
+ attributes: fields.map { |field| attribute(field) },
51
+ nested: groups.map { |group| nested_group(group) },
52
+ }
53
+ end
54
+
55
+ # An association's own fields are reported as the form declared them and
56
+ # are not annotated from a model: they belong to the associated record, not
57
+ # to the one being written.
58
+ def nested_group(group)
59
+ { name: group[:name].to_s, attributes: group[:nested].map { |field| stringify(field) } }
60
+ end
61
+
62
+ def attribute(field)
63
+ described = stringify(field)
64
+ described[:type] = column_type(field[:name]) if column_type(field[:name])
65
+ described[:required] = true if !described.key?(:required) && presence_validated?(field[:name])
66
+ described
67
+ end
68
+
69
+ def stringify(field)
70
+ field.each_with_object({}) do |(key, value), described|
71
+ described[key] = value.is_a?(Symbol) ? value.to_s : value
72
+ end
73
+ end
74
+
75
+ def column_type(name)
76
+ @resource[:model].columns_hash[name.to_s]&.type&.to_s
77
+ end
78
+
79
+ def presence_validated?(name)
80
+ @resource[:model].validators_on(name).any? do |validator|
81
+ validator.is_a?(ActiveModel::Validations::PresenceValidator)
82
+ end
83
+ rescue StandardError
84
+ false
85
+ end
86
+
87
+ def declared_inputs
88
+ block = form_block
89
+ return nil unless block
90
+
91
+ FormFieldCollector.new.collect(&block)
92
+ rescue StandardError => e
93
+ # A form block that will not run outside a request describes nothing, but
94
+ # the resource's permitted params still can.
95
+ warn("[activeadmin_mcp] reading the #{@resource[:name]} form raised #{e.class}: #{e.message}")
96
+ nil
97
+ end
98
+
99
+ def form_block
100
+ return nil unless @config.respond_to?(:page_presenters)
101
+
102
+ @config.page_presenters[:form]&.block
103
+ end
104
+
105
+ # ActiveAdmin stores no list of the params it permits, only a method that
106
+ # filters against them, so the permitted names are recovered by offering it
107
+ # every column the model has and seeing which survive. A resource that
108
+ # never declared permit_params has no such method to answer, and is
109
+ # reported as unwritable rather than described.
110
+ def permitted_inputs
111
+ names = permitted_names
112
+ return nil unless names
113
+
114
+ names.map { |name| { name: name } }
115
+ end
116
+
117
+ def permitted_names
118
+ param_key = @config.param_key.to_sym
119
+ controller = @config.controller.new
120
+ controller.params = ActionController::Parameters.new(
121
+ param_key => @resource[:model].column_names.index_with { nil }
122
+ )
123
+ permitted = controller.send(:permitted_params)
124
+ scoped = permitted && permitted[param_key]
125
+ scoped&.keys&.map(&:to_sym)
126
+ rescue StandardError
127
+ nil
128
+ end
129
+
130
+ def write_refusal(write_action)
131
+ unless @config.defined_actions.include?(write_action)
132
+ return error("Resource #{REFUSALS[write_action]}: #{@resource[:name]}")
133
+ end
134
+
135
+ return if Authorization.for(@config, @current_user)
136
+ .authorized?(write_action, @config.resource_class)
137
+
138
+ error("Not authorized to #{write_action} #{@resource[:name]}")
139
+ end
140
+
141
+ def permit_params_refusal
142
+ error("Resource declares no permit_params, so nothing may be written: #{@resource[:name]}")
143
+ end
144
+
145
+ def error(message)
146
+ { error: message }
147
+ end
148
+ end
149
+ end
@@ -1,29 +1,34 @@
1
- # frozen_string_literal: true
2
-
3
1
  module ActiveadminMcp
4
- # Records the field names declared by an ActiveAdmin `form do ... end` block.
2
+ # Reads an ActiveAdmin `form do ... end` block and reports what each input
3
+ # tells a client about how to fill the field in.
5
4
  #
6
5
  # ActiveAdmin form blocks are arbitrary Formtastic DSL — `input`, `inputs`,
7
- # `actions`, helper calls, conditionals — so the block is run against this
8
- # stand-in form builder. Every `input :field` records `:field`; every other
9
- # message (including unknown helpers) is swallowed and returns self, so the
10
- # block executes without a real view context. Nested `has_many` associations
11
- # are intentionally not descended into: the updater only writes flat
12
- # attributes, and descending would record association fields as top-level.
6
+ # `actions`, application helpers, conditionals — so the block is run against
7
+ # this stand-in form builder rather than parsed. Every `input :field` is
8
+ # recorded; every other message (including helpers that would need a view
9
+ # context we do not have) is swallowed and returns self, so the block runs to
10
+ # completion outside a request.
11
+ #
12
+ # Only the options that describe the field to whoever is filling it in are
13
+ # kept. Presentation options (`input_html:`, `wrapper_html:` and the rest)
14
+ # say nothing an MCP client can act on and are dropped.
13
15
  class FormFieldCollector
14
- attr_reader :fields
16
+ DESCRIBED_TEXT_OPTIONS = %i[label hint].freeze
15
17
 
16
18
  def initialize
17
- @fields = []
19
+ @inputs = []
18
20
  end
19
21
 
20
22
  def collect(&block)
21
23
  instance_exec(self, &block)
22
- @fields.uniq
24
+ @inputs
23
25
  end
24
26
 
25
- def input(name, *_args, **_opts, &_block)
26
- @fields << name.to_sym if name.respond_to?(:to_sym)
27
+ def input(name, *_args, **options, &_block)
28
+ return self unless name.respond_to?(:to_sym)
29
+ return self if declared?(name.to_sym)
30
+
31
+ @inputs << describe(name.to_sym, options)
27
32
  self
28
33
  end
29
34
 
@@ -32,7 +37,14 @@ module ActiveadminMcp
32
37
  self
33
38
  end
34
39
 
35
- def has_many(*_args, **_opts)
40
+ # Recorded as a group of its own rather than descended into, so a client
41
+ # can tell an association's fields from the record's own. Flattening them
42
+ # would advertise `body` as an attribute of the parent record.
43
+ def has_many(name, *_args, **_opts, &block)
44
+ return self unless name.respond_to?(:to_sym)
45
+ return self if declared?(name.to_sym)
46
+
47
+ @inputs << { name: name.to_sym, nested: block ? self.class.new.collect(&block) : [] }
36
48
  self
37
49
  end
38
50
 
@@ -44,5 +56,46 @@ module ActiveadminMcp
44
56
  def respond_to_missing?(_name, _include_private = false)
45
57
  true
46
58
  end
59
+
60
+ private
61
+
62
+ def declared?(name)
63
+ @inputs.any? { |input| input[:name] == name }
64
+ end
65
+
66
+ def describe(name, options)
67
+ described = { name: name }
68
+
69
+ described[:as] = options[:as].to_sym if scalar_name?(options[:as])
70
+ described[:required] = options[:required] if [true, false].include?(options[:required])
71
+
72
+ DESCRIBED_TEXT_OPTIONS.each do |key|
73
+ described[key] = options[key].to_s if scalar_name?(options[key])
74
+ end
75
+
76
+ values = allowed_values(options[:collection])
77
+ described[:collection] = values if values
78
+
79
+ described
80
+ end
81
+
82
+ # A helper the collector swallowed comes back as the collector itself, so
83
+ # anything that is not plain text is not something to report as a label.
84
+ def scalar_name?(value)
85
+ value.is_a?(String) || value.is_a?(Symbol)
86
+ end
87
+
88
+ # Only a literal array is resolved. A relation would mean firing a query
89
+ # from what is meant to be a description, and can be arbitrarily large; a
90
+ # proc usually needs the view context this collector does not have. Either
91
+ # is omitted rather than evaluated.
92
+ def allowed_values(collection)
93
+ return nil unless collection.is_a?(Array)
94
+
95
+ values = collection.map { |entry| entry.is_a?(Array) ? entry.last : entry }
96
+ return nil unless values.all? { |value| scalar_name?(value) || value.is_a?(Numeric) }
97
+
98
+ values
99
+ end
47
100
  end
48
101
  end
@@ -0,0 +1,161 @@
1
+ module ActiveadminMcp
2
+ # Creates and updates ActiveAdmin-managed records by dispatching the
3
+ # resource's own create and update actions, so a write from an MCP client
4
+ # goes through the same permitted params, callbacks, before_action chain and
5
+ # authorization as the same write made by clicking Save in the admin UI.
6
+ #
7
+ # Three gates run before anything is dispatched: the resource must expose the
8
+ # action, the authorization adapter must allow it, and the resource must
9
+ # declare what may be written. The controller then applies all three again,
10
+ # against the record it builds or loads.
11
+ class RecordWriter
12
+ CREATE = :create
13
+ UPDATE = :update
14
+
15
+ def initialize(resource:, current_user:)
16
+ @resource = resource
17
+ @current_user = current_user
18
+ @config = resource[:config]
19
+ end
20
+
21
+ def create(attributes:)
22
+ return error("Resource is not creatable: #{resource_name}") unless exposes?(CREATE)
23
+
24
+ permitted = resolve_permitted(attributes)
25
+ refusal = permit_refusal(permitted)
26
+ return refusal if refusal
27
+ return error("Not authorized to create #{resource_name}") unless authorized?(CREATE, @config.resource_class)
28
+
29
+ write(
30
+ action: CREATE,
31
+ verb: :post,
32
+ path: @config.route_collection_path,
33
+ attributes: attributes,
34
+ written: permitted.keys,
35
+ written_key: :created,
36
+ description: "Create #{resource_name}"
37
+ )
38
+ end
39
+
40
+ def update(id:, attributes:)
41
+ return error("Resource is not editable: #{resource_name}") unless exposes?(UPDATE)
42
+
43
+ record = @resource[:model].find_by(id: id)
44
+ return error("Record not found: #{resource_name}##{id}") unless record
45
+
46
+ permitted = resolve_permitted(attributes)
47
+ refusal = permit_refusal(permitted)
48
+ return refusal if refusal
49
+ return error("Not authorized to update #{resource_name}##{id}") unless authorized?(UPDATE, record)
50
+
51
+ write(
52
+ action: UPDATE,
53
+ verb: :patch,
54
+ path: @config.route_instance_path(record),
55
+ attributes: attributes,
56
+ written: permitted.keys,
57
+ written_key: :updated,
58
+ path_params: { id: record.to_param },
59
+ description: "Update #{resource_name}##{id}"
60
+ )
61
+ end
62
+
63
+ private
64
+
65
+ def write(action:, verb:, path:, attributes:, written:, written_key:, description:, path_params: {})
66
+ record = nil
67
+
68
+ outcome = ControllerDispatcher.new(config: @config, current_user: @current_user).call(
69
+ action: action,
70
+ verb: verb,
71
+ path: path,
72
+ params: { @config.param_key => attributes },
73
+ path_params: path_params
74
+ ) { |controller| record = controller.send(:get_resource_ivar) }
75
+
76
+ return rejection(record, description, outcome) unless saved?(record)
77
+
78
+ {
79
+ resource: resource_name,
80
+ id: record.id,
81
+ written_key => written,
82
+ record: without_sensitive_attributes(record),
83
+ }
84
+ end
85
+
86
+ def saved?(record)
87
+ !record.nil? && record.persisted? && record.errors.empty?
88
+ end
89
+
90
+ # The controller declined to save. Validation messages are the usual
91
+ # reason, and they are read off the record rather than the response
92
+ # because a rejected write re-renders the admin form — a render the
93
+ # synthesized request may well not survive, leaving the dispatch itself
94
+ # reported as a failure even though the record knows exactly what was
95
+ # wrong with it.
96
+ #
97
+ # With no record and no validation messages, anything the controller put
98
+ # in the flash is the best account left of what the admin UI would have
99
+ # told the user.
100
+ def rejection(record, description, outcome)
101
+ messages = record ? record.errors.full_messages : []
102
+ return error("Validation failed", details: messages) if messages.any?
103
+ return outcome if outcome[:error]
104
+
105
+ error("#{description} failed", details: Array(outcome[:flash]&.values).presence)
106
+ end
107
+
108
+ def exposes?(action)
109
+ @config.defined_actions.include?(action)
110
+ end
111
+
112
+ def authorized?(action, subject)
113
+ Authorization.for(@config, @current_user).authorized?(action, subject)
114
+ end
115
+
116
+ # Turns what the controller says it would permit into a refusal, or nil to
117
+ # go ahead. The answer is not used to filter the write — dispatch means
118
+ # ActiveAdmin applies permit_params itself — but to refuse early, and with
119
+ # a reason, in the two cases where dispatching would otherwise fail
120
+ # obscurely: a resource that never declared permit_params, and a call whose
121
+ # every attribute would be dropped.
122
+ #
123
+ # A resource with no permit_params cannot be written through ActiveAdmin's
124
+ # own forms either — Rails raises ForbiddenAttributesError on the
125
+ # unpermitted params — so refusing it says what the admin UI would.
126
+ def permit_refusal(permitted)
127
+ if permitted.nil?
128
+ return error("Resource declares no permit_params, so nothing may be written: #{resource_name}")
129
+ end
130
+
131
+ error("No permitted attributes to write") if permitted.empty?
132
+ end
133
+
134
+ # Asks the resource's own controller what it would permit, or nil when it
135
+ # has nothing to say because permit_params was never declared.
136
+ def resolve_permitted(attributes)
137
+ param_key = @config.param_key.to_sym
138
+ controller = @config.controller.new
139
+ controller.params = ActionController::Parameters.new(param_key => attributes)
140
+ permitted = controller.send(:permitted_params)
141
+ scoped = permitted && permitted[param_key]
142
+ scoped&.to_h&.symbolize_keys
143
+ rescue StandardError
144
+ nil
145
+ end
146
+
147
+ def without_sensitive_attributes(record)
148
+ record.as_json.except(*ResourceRegistry.sensitive_attributes)
149
+ end
150
+
151
+ def resource_name
152
+ @resource[:name]
153
+ end
154
+
155
+ def error(message, details: nil)
156
+ result = { error: message }
157
+ result[:details] = details if details
158
+ result
159
+ end
160
+ end
161
+ end
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  module ActiveadminMcp
4
2
  class RequestHandler
5
3
  PROTOCOL_VERSION = "2025-06-18"
@@ -40,45 +38,131 @@ module ActiveadminMcp
40
38
  end
41
39
 
42
40
  def tools_list
43
- {
44
- tools: [
45
- {
46
- name: "list_resources",
47
- description: "List the ActiveAdmin resources the authenticated user is authorized " \
48
- "to read, with their attributes",
49
- inputSchema: { type: "object", properties: {} },
41
+ { tools: built_in_tools + action_tools }
42
+ end
43
+
44
+ # Both gates run before the schema is built, never after: ActionSchema calls
45
+ # the application's `suggestions:` procs, which read the database. Filtering
46
+ # an already-assembled list would mean those procs had already run — and
47
+ # their values already been read — for a user authorized for none of it.
48
+ def action_tools
49
+ ActionCatalog.all.filter_map do |definition|
50
+ next unless authorized_to_run?(definition)
51
+ next unless authorized_to_list?(definition)
52
+
53
+ {
54
+ name: definition.tool_name,
55
+ description: definition.description,
56
+ inputSchema: ActionSchema.new(definition).to_h,
57
+ }
58
+ end
59
+ end
60
+
61
+ # The same authorization adapter check ActionRunner makes before dispatch,
62
+ # and the one list_resources makes for reads. There is no record at listing
63
+ # time, so the subject is the resource class.
64
+ def authorized_to_run?(definition)
65
+ Authorization.for(definition.config, @current_user)
66
+ .authorized?(definition.action_name, definition.config.resource_class)
67
+ rescue StandardError => e
68
+ warn("[activeadmin_mcp] hiding #{definition.tool_name}: authorization check raised #{e.class}: #{e.message}")
69
+ false
70
+ end
71
+
72
+ # Collection and batch actions whose permission proc takes no record can be
73
+ # resolved now, so the tool is simply hidden. A member action's proc needs a
74
+ # record, so its tool stays listed and refusal happens at call time.
75
+ #
76
+ # The proc is evaluated in controller context, exactly as ActionRunner
77
+ # evaluates it at call time, so `current_admin_user`, `can?` and the rest of
78
+ # the admin helpers are in scope. Evaluating it bare would make every such
79
+ # proc raise NameError and hide its tool from everybody.
80
+ def authorized_to_list?(definition)
81
+ permission = definition.permission
82
+ return true unless permission
83
+ return true unless permission.respond_to?(:arity) && permission.arity.zero?
84
+
85
+ controller = ControllerDispatcher.new(config: definition.config, current_user: @current_user)
86
+ .controller_with_mcp_user
87
+ !!::MethodOrProcHelper.render_in_context(controller, permission)
88
+ rescue StandardError => e
89
+ # One broken proc hides its own tool and nothing else, but it says so.
90
+ warn("[activeadmin_mcp] hiding #{definition.tool_name}: permission proc raised #{e.class}: #{e.message}")
91
+ false
92
+ end
93
+
94
+ def built_in_tools
95
+ [
96
+ {
97
+ name: "list_resources",
98
+ description: "List the ActiveAdmin resources the authenticated user is authorized " \
99
+ "to read, with their attributes",
100
+ inputSchema: { type: "object", properties: {} },
101
+ },
102
+ {
103
+ name: "query",
104
+ description: "Query an ActiveAdmin resource using Ransack syntax. Respects ActiveAdmin " \
105
+ "authorization: the resource must be readable by the authenticated user, " \
106
+ "and results are scoped to the records they may access.",
107
+ inputSchema: {
108
+ type: "object",
109
+ properties: {
110
+ resource: { type: "string", description: "Resource name (e.g., 'User', 'Post')" },
111
+ q: { type: "object", description: "Ransack query (e.g., {name_cont: 'john'})" },
112
+ limit: { type: "integer", description: "Max records (default: 25)" },
113
+ },
114
+ required: ["resource"],
50
115
  },
51
- {
52
- name: "query",
53
- description: "Query an ActiveAdmin resource using Ransack syntax. Respects ActiveAdmin " \
54
- "authorization: the resource must be readable by the authenticated user, " \
55
- "and results are scoped to the records they may access.",
56
- inputSchema: {
57
- type: "object",
58
- properties: {
59
- resource: { type: "string", description: "Resource name (e.g., 'User', 'Post')" },
60
- q: { type: "object", description: "Ransack query (e.g., {name_cont: 'john'})" },
61
- limit: { type: "integer", description: "Max records (default: 25)" },
116
+ },
117
+ {
118
+ name: "describe_form",
119
+ description: "Describe the fields of a resource's ActiveAdmin form — their input " \
120
+ "types, labels, hints, allowed values, column types and which are " \
121
+ "required — so a create or update call need not guess them.",
122
+ inputSchema: {
123
+ type: "object",
124
+ properties: {
125
+ resource: { type: "string", description: "Resource name (e.g., 'User', 'Post')" },
126
+ action: {
127
+ type: "string",
128
+ enum: %w[new edit],
129
+ description: "Which form to describe: 'new' for creating, 'edit' for updating " \
130
+ "(default: 'new')",
62
131
  },
63
- required: ["resource"],
64
132
  },
133
+ required: ["resource"],
65
134
  },
66
- {
67
- name: "update",
68
- description: "Update an existing record. Only fields the resource's ActiveAdmin " \
69
- "form permits are written, and the update respects ActiveAdmin authorization.",
70
- inputSchema: {
71
- type: "object",
72
- properties: {
73
- resource: { type: "string", description: "Resource name (e.g., 'User', 'Post')" },
74
- id: { type: ["integer", "string"], description: "Primary key of the record to update" },
75
- attributes: { type: "object", description: "Attributes to update (e.g., {name: 'New name'})" },
76
- },
77
- required: %w[resource id attributes],
135
+ },
136
+ {
137
+ name: "create",
138
+ description: "Create a new record. The write runs through the resource's real " \
139
+ "ActiveAdmin create action, so only fields its permit_params accepts " \
140
+ "are written and its callbacks and authorization all apply.",
141
+ inputSchema: {
142
+ type: "object",
143
+ properties: {
144
+ resource: { type: "string", description: "Resource name (e.g., 'User', 'Post')" },
145
+ attributes: { type: "object", description: "Attributes for the new record (e.g., {name: 'Ada'})" },
78
146
  },
147
+ required: %w[resource attributes],
79
148
  },
80
- ],
81
- }
149
+ },
150
+ {
151
+ name: "update",
152
+ description: "Update an existing record. The write runs through the resource's real " \
153
+ "ActiveAdmin update action, so only fields its permit_params accepts " \
154
+ "are written and its callbacks and authorization all apply.",
155
+ inputSchema: {
156
+ type: "object",
157
+ properties: {
158
+ resource: { type: "string", description: "Resource name (e.g., 'User', 'Post')" },
159
+ id: { type: ["integer", "string"], description: "Primary key of the record to update" },
160
+ attributes: { type: "object", description: "Attributes to update (e.g., {name: 'New name'})" },
161
+ },
162
+ required: %w[resource id attributes],
163
+ },
164
+ },
165
+ ]
82
166
  end
83
167
 
84
168
  def call_tool(params)
@@ -88,13 +172,22 @@ module ActiveadminMcp
88
172
  result = case name
89
173
  when "list_resources" then tool_list_resources
90
174
  when "query" then tool_query(args)
175
+ when "describe_form" then tool_describe_form(args)
176
+ when "create" then tool_create(args)
91
177
  when "update" then tool_update(args)
92
- else { error: "Unknown tool: #{name}" }
178
+ else tool_action(name, args)
93
179
  end
94
180
 
95
181
  { content: [{ type: "text", text: JSON.pretty_generate(result) }] }
96
182
  end
97
183
 
184
+ def tool_action(name, args)
185
+ definition = ActionCatalog.find(name)
186
+ return { error: "Unknown tool: #{name}" } unless definition
187
+
188
+ ActionRunner.new(definition: definition, current_user: @current_user).call(args)
189
+ end
190
+
98
191
  def tool_list_resources
99
192
  entries = ResourceRegistry.resources.select { |entry| authorized_to_read?(entry) }
100
193
  { resources: entries.map { |entry| ResourceRegistry.resource_info(entry) } }
@@ -113,6 +206,24 @@ module ActiveadminMcp
113
206
  { resource: resource[:name], count: records.size, records: filter_sensitive(records.as_json) }
114
207
  end
115
208
 
209
+ def tool_describe_form(args)
210
+ resource = ResourceRegistry.find(args["resource"])
211
+ return { error: "Resource not found: #{args['resource']}" } unless resource
212
+
213
+ FormDescription.new(resource: resource, current_user: @current_user)
214
+ .call(action: args["action"] || "new")
215
+ end
216
+
217
+ def tool_create(args)
218
+ resource = ResourceRegistry.find(args["resource"])
219
+ return { error: "Resource not found: #{args['resource']}" } unless resource
220
+
221
+ attributes = args["attributes"] || {}
222
+ return { error: "attributes are required" } if attributes.empty?
223
+
224
+ RecordWriter.new(resource: resource, current_user: @current_user).create(attributes: attributes)
225
+ end
226
+
116
227
  def tool_update(args)
117
228
  resource = ResourceRegistry.find(args["resource"])
118
229
  return { error: "Resource not found: #{args['resource']}" } unless resource
@@ -121,8 +232,8 @@ module ActiveadminMcp
121
232
  attributes = args["attributes"] || {}
122
233
  return { error: "attributes are required" } if attributes.empty?
123
234
 
124
- RecordUpdater.new(resource: resource, current_user: @current_user)
125
- .call(id: args["id"], attributes: attributes)
235
+ RecordWriter.new(resource: resource, current_user: @current_user)
236
+ .update(id: args["id"], attributes: attributes)
126
237
  end
127
238
 
128
239
  def authorized_to_read?(resource)
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  module ActiveadminMcp
4
2
  module ResourceRegistry
5
3
  class << self