activeadmin_mcp 0.0.4 → 0.2.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.
@@ -0,0 +1,167 @@
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
+ #
137
+ # The controller carries the MCP user, because ActiveAdmin instance_execs a
138
+ # block-form permit_params on it: a block reading current_admin_user raises
139
+ # on a bare instance, and the rescue below would then report a resource
140
+ # that permits plenty as one that permits nothing.
141
+ def resolve_permitted(attributes)
142
+ param_key = @config.param_key.to_sym
143
+ controller = ControllerDispatcher.new(config: @config, current_user: @current_user)
144
+ .controller_with_mcp_user
145
+ controller.params = ActionController::Parameters.new(param_key => attributes)
146
+ permitted = controller.send(:permitted_params)
147
+ scoped = permitted && permitted[param_key]
148
+ scoped&.to_h&.symbolize_keys
149
+ rescue StandardError
150
+ nil
151
+ end
152
+
153
+ def without_sensitive_attributes(record)
154
+ record.as_json.except(*ResourceRegistry.sensitive_attributes)
155
+ end
156
+
157
+ def resource_name
158
+ @resource[:name]
159
+ end
160
+
161
+ def error(message, details: nil)
162
+ result = { error: message }
163
+ result[:details] = details if details
164
+ result
165
+ end
166
+ end
167
+ 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,149 @@ 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(current_user: @current_user).filter_map do |definition|
50
+ next unless authorized_to_run?(definition)
51
+ next unless offered_by_active_admin?(definition)
52
+ next unless authorized_to_list?(definition)
53
+
54
+ {
55
+ name: definition.tool_name,
56
+ description: definition.description,
57
+ inputSchema: ActionSchema.new(definition).to_h,
58
+ }
59
+ end
60
+ end
61
+
62
+ # The same authorization adapter check ActionRunner makes before dispatch,
63
+ # and the one list_resources makes for reads. There is no record at listing
64
+ # time, so the subject is the resource class.
65
+ def authorized_to_run?(definition)
66
+ Authorization.for(definition.config, @current_user)
67
+ .authorized?(definition.action_name, definition.config.resource_class)
68
+ rescue StandardError => e
69
+ warn("[activeadmin_mcp] hiding #{definition.tool_name}: authorization check raised #{e.class}: #{e.message}")
70
+ false
71
+ end
72
+
73
+ # ActiveAdmin's own `:if` proc on a batch action decides whether the admin
74
+ # UI offers it. Evaluated in controller context, as ActiveAdmin evaluates
75
+ # it, so `authorized?` and `current_admin_user` are in scope. A proc
76
+ # reaching for request state it cannot have here raises, and the tool is
77
+ # hidden rather than offered past a gate we could not read.
78
+ def offered_by_active_admin?(definition)
79
+ block = definition.display_if
80
+ return true unless block
81
+
82
+ controller = ControllerDispatcher.new(config: definition.config, current_user: @current_user)
83
+ .controller_with_mcp_user
84
+ !!::MethodOrProcHelper.render_in_context(controller, block)
85
+ rescue StandardError => e
86
+ warn("[activeadmin_mcp] hiding #{definition.tool_name}: if: proc raised #{e.class}: #{e.message}")
87
+ false
88
+ end
89
+
90
+ # Collection and batch actions whose permission proc takes no record can be
91
+ # resolved now, so the tool is simply hidden. A member action's proc needs a
92
+ # record, so its tool stays listed and refusal happens at call time.
93
+ #
94
+ # The proc is evaluated in controller context, exactly as ActionRunner
95
+ # evaluates it at call time, so `current_admin_user`, `can?` and the rest of
96
+ # the admin helpers are in scope. Evaluating it bare would make every such
97
+ # proc raise NameError and hide its tool from everybody.
98
+ def authorized_to_list?(definition)
99
+ permission = definition.permission
100
+ return true unless permission
101
+ return true unless permission.respond_to?(:arity) && permission.arity.zero?
102
+
103
+ controller = ControllerDispatcher.new(config: definition.config, current_user: @current_user)
104
+ .controller_with_mcp_user
105
+ !!::MethodOrProcHelper.render_in_context(controller, permission)
106
+ rescue StandardError => e
107
+ # One broken proc hides its own tool and nothing else, but it says so.
108
+ warn("[activeadmin_mcp] hiding #{definition.tool_name}: permission proc raised #{e.class}: #{e.message}")
109
+ false
110
+ end
111
+
112
+ def built_in_tools
113
+ [
114
+ {
115
+ name: "list_resources",
116
+ description: "List the ActiveAdmin resources the authenticated user is authorized " \
117
+ "to read, with their attributes",
118
+ inputSchema: { type: "object", properties: {} },
119
+ },
120
+ {
121
+ name: "query",
122
+ description: "Query an ActiveAdmin resource using Ransack syntax. Respects ActiveAdmin " \
123
+ "authorization: the resource must be readable by the authenticated user, " \
124
+ "and results are scoped to the records they may access.",
125
+ inputSchema: {
126
+ type: "object",
127
+ properties: {
128
+ resource: { type: "string", description: "Resource name (e.g., 'User', 'Post')" },
129
+ q: { type: "object", description: "Ransack query (e.g., {name_cont: 'john'})" },
130
+ limit: { type: "integer", description: "Max records (default: 25)" },
131
+ },
132
+ required: ["resource"],
50
133
  },
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)" },
134
+ },
135
+ {
136
+ name: "describe_form",
137
+ description: "Describe the fields of a resource's ActiveAdmin form — their input " \
138
+ "types, labels, hints, allowed values, column types and which are " \
139
+ "required — so a create or update call need not guess them.",
140
+ inputSchema: {
141
+ type: "object",
142
+ properties: {
143
+ resource: { type: "string", description: "Resource name (e.g., 'User', 'Post')" },
144
+ action: {
145
+ type: "string",
146
+ enum: %w[new edit],
147
+ description: "Which form to describe: 'new' for creating, 'edit' for updating " \
148
+ "(default: 'new')",
62
149
  },
63
- required: ["resource"],
64
150
  },
151
+ required: ["resource"],
65
152
  },
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],
153
+ },
154
+ {
155
+ name: "create",
156
+ description: "Create a new record. The write runs through the resource's real " \
157
+ "ActiveAdmin create action, so only fields its permit_params accepts " \
158
+ "are written and its callbacks and authorization all apply.",
159
+ inputSchema: {
160
+ type: "object",
161
+ properties: {
162
+ resource: { type: "string", description: "Resource name (e.g., 'User', 'Post')" },
163
+ attributes: { type: "object", description: "Attributes for the new record (e.g., {name: 'Ada'})" },
78
164
  },
165
+ required: %w[resource attributes],
79
166
  },
80
- ],
81
- }
167
+ },
168
+ {
169
+ name: "update",
170
+ description: "Update an existing record. The write runs through the resource's real " \
171
+ "ActiveAdmin update action, so only fields its permit_params accepts " \
172
+ "are written and its callbacks and authorization all apply.",
173
+ inputSchema: {
174
+ type: "object",
175
+ properties: {
176
+ resource: { type: "string", description: "Resource name (e.g., 'User', 'Post')" },
177
+ id: { type: ["integer", "string"], description: "Primary key of the record to update" },
178
+ attributes: { type: "object", description: "Attributes to update (e.g., {name: 'New name'})" },
179
+ },
180
+ required: %w[resource id attributes],
181
+ },
182
+ },
183
+ ]
82
184
  end
83
185
 
84
186
  def call_tool(params)
@@ -88,13 +190,22 @@ module ActiveadminMcp
88
190
  result = case name
89
191
  when "list_resources" then tool_list_resources
90
192
  when "query" then tool_query(args)
193
+ when "describe_form" then tool_describe_form(args)
194
+ when "create" then tool_create(args)
91
195
  when "update" then tool_update(args)
92
- else { error: "Unknown tool: #{name}" }
196
+ else tool_action(name, args)
93
197
  end
94
198
 
95
199
  { content: [{ type: "text", text: JSON.pretty_generate(result) }] }
96
200
  end
97
201
 
202
+ def tool_action(name, args)
203
+ definition = ActionCatalog.find(name, current_user: @current_user)
204
+ return { error: "Unknown tool: #{name}" } unless definition
205
+
206
+ ActionRunner.new(definition: definition, current_user: @current_user).call(args)
207
+ end
208
+
98
209
  def tool_list_resources
99
210
  entries = ResourceRegistry.resources.select { |entry| authorized_to_read?(entry) }
100
211
  { resources: entries.map { |entry| ResourceRegistry.resource_info(entry) } }
@@ -113,6 +224,24 @@ module ActiveadminMcp
113
224
  { resource: resource[:name], count: records.size, records: filter_sensitive(records.as_json) }
114
225
  end
115
226
 
227
+ def tool_describe_form(args)
228
+ resource = ResourceRegistry.find(args["resource"])
229
+ return { error: "Resource not found: #{args['resource']}" } unless resource
230
+
231
+ FormDescription.new(resource: resource, current_user: @current_user)
232
+ .call(action: args["action"] || "new")
233
+ end
234
+
235
+ def tool_create(args)
236
+ resource = ResourceRegistry.find(args["resource"])
237
+ return { error: "Resource not found: #{args['resource']}" } unless resource
238
+
239
+ attributes = args["attributes"] || {}
240
+ return { error: "attributes are required" } if attributes.empty?
241
+
242
+ RecordWriter.new(resource: resource, current_user: @current_user).create(attributes: attributes)
243
+ end
244
+
116
245
  def tool_update(args)
117
246
  resource = ResourceRegistry.find(args["resource"])
118
247
  return { error: "Resource not found: #{args['resource']}" } unless resource
@@ -121,8 +250,8 @@ module ActiveadminMcp
121
250
  attributes = args["attributes"] || {}
122
251
  return { error: "attributes are required" } if attributes.empty?
123
252
 
124
- RecordUpdater.new(resource: resource, current_user: @current_user)
125
- .call(id: args["id"], attributes: attributes)
253
+ RecordWriter.new(resource: resource, current_user: @current_user)
254
+ .update(id: args["id"], attributes: attributes)
126
255
  end
127
256
 
128
257
  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
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  module ActiveadminMcp
4
- VERSION = "0.0.4"
2
+ VERSION = "0.2.0"
5
3
  end
@@ -1,11 +1,17 @@
1
- # frozen_string_literal: true
2
-
3
1
  require_relative "activeadmin_mcp/version"
4
2
  require_relative "activeadmin_mcp/configuration"
5
3
  require_relative "activeadmin_mcp/authorization"
4
+ require_relative "activeadmin_mcp/active_admin_ext"
5
+ require_relative "activeadmin_mcp/action_definition"
6
+ require_relative "activeadmin_mcp/action_schema"
7
+ require_relative "activeadmin_mcp/action_params"
8
+ require_relative "activeadmin_mcp/action_catalog"
9
+ require_relative "activeadmin_mcp/controller_dispatcher"
10
+ require_relative "activeadmin_mcp/action_runner"
6
11
  require_relative "activeadmin_mcp/resource_registry"
7
12
  require_relative "activeadmin_mcp/form_field_collector"
8
- require_relative "activeadmin_mcp/record_updater"
13
+ require_relative "activeadmin_mcp/form_description"
14
+ require_relative "activeadmin_mcp/record_writer"
9
15
  require_relative "activeadmin_mcp/request_handler"
10
16
  require_relative "activeadmin_mcp/engine"
11
17
 
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  require "rails/generators"
4
2
  require "rails/generators/active_record"
5
3
 
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  ActiveadminMcp.configure do |config|
4
2
  # Uncomment to enable API token authentication.
5
3
  # Requires running the auth migration first:
@@ -19,6 +17,11 @@ ActiveadminMcp.configure do |config|
19
17
  # Path where the MCP server is mounted.
20
18
  # config.mount_path = "/mcp"
21
19
 
20
+ # How the engine adds its route: :prepend (default), :append, or :none.
21
+ # Set it to :none when you want to mount the engine yourself, for instance
22
+ # inside a constraints block your admin routes already sit in.
23
+ # config.mount_strategy = :prepend
24
+
22
25
  # HTTP header used to read the Bearer token from.
23
26
  # Useful when a reverse proxy (e.g. AWS Verified Access) strips the
24
27
  # standard Authorization header.
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  ActiveAdmin.register_page "MCP API Tokens" do
4
2
  menu label: "MCP Tokens", parent: ActiveadminMcp.config.menu_parent, priority: 100
5
3
 
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  class CreateMcpApiTokens < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
2
  def change
5
3
  return if table_exists?(:mcp_api_tokens)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activeadmin_mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.4
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - harunkumars
@@ -16,28 +16,28 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '6.1'
19
+ version: '7.2'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: '6.1'
26
+ version: '7.2'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: activeadmin
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - ">="
31
+ - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '2.0'
33
+ version: '3.5'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
- - - ">="
38
+ - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '2.0'
40
+ version: '3.5'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: rake
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -95,11 +95,19 @@ files:
95
95
  - app/models/activeadmin_mcp/api_token.rb
96
96
  - config/routes.rb
97
97
  - lib/activeadmin_mcp.rb
98
+ - lib/activeadmin_mcp/action_catalog.rb
99
+ - lib/activeadmin_mcp/action_definition.rb
100
+ - lib/activeadmin_mcp/action_params.rb
101
+ - lib/activeadmin_mcp/action_runner.rb
102
+ - lib/activeadmin_mcp/action_schema.rb
103
+ - lib/activeadmin_mcp/active_admin_ext.rb
98
104
  - lib/activeadmin_mcp/authorization.rb
99
105
  - lib/activeadmin_mcp/configuration.rb
106
+ - lib/activeadmin_mcp/controller_dispatcher.rb
100
107
  - lib/activeadmin_mcp/engine.rb
108
+ - lib/activeadmin_mcp/form_description.rb
101
109
  - lib/activeadmin_mcp/form_field_collector.rb
102
- - lib/activeadmin_mcp/record_updater.rb
110
+ - lib/activeadmin_mcp/record_writer.rb
103
111
  - lib/activeadmin_mcp/request_handler.rb
104
112
  - lib/activeadmin_mcp/resource_registry.rb
105
113
  - lib/activeadmin_mcp/version.rb
@@ -122,14 +130,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
122
130
  requirements:
123
131
  - - ">="
124
132
  - !ruby/object:Gem::Version
125
- version: 3.0.0
133
+ version: 4.0.0
126
134
  required_rubygems_version: !ruby/object:Gem::Requirement
127
135
  requirements:
128
136
  - - ">="
129
137
  - !ruby/object:Gem::Version
130
138
  version: '0'
131
139
  requirements: []
132
- rubygems_version: 3.6.9
140
+ rubygems_version: 4.0.20
133
141
  specification_version: 4
134
142
  summary: MCP server for Rails apps with ActiveAdmin
135
143
  test_files: []