activeadmin_mcp 0.0.1 → 0.0.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 99312ac0bef360e80a8d058b79adf5f0af182d3bedc19fdfbf4caa69964f7e44
4
- data.tar.gz: bba982b59472e271c18214bc4674e60a3ae96729b78172163d54eed756b3997c
3
+ metadata.gz: 1d97e042cb6e4af51f5939d4edd39a96f7193ece948a05a70dd8e18851f1bc85
4
+ data.tar.gz: 6b91b93d2d0511f3bea310f62044589eb35b90358547e942d379be214582d5e7
5
5
  SHA512:
6
- metadata.gz: 861ea8e3d8eeff901f12f4fa99385db60fd0854a0eae22462cf314cce67820632033b8e55ebc13252bcaa3389f0b6aceda4cc712d5bceb4ba0c342ac10a5815d
7
- data.tar.gz: 035b92456d5b3ca111c5e4715c8f495f52531ab053eb7974f1c1dda12de73d87f08ba34b40f226723d8a207bc98a89c929c4d6bb8ecdc9ee0873fdc6fbefb275
6
+ metadata.gz: 6714a33bbef7072a22f067f422837e6ccf88ebd6a835157c8ffa8fff5487791ee2494fbcd1316ac4ca65c9767e53ac6e48a7c672e84846615fb77af29b507080
7
+ data.tar.gz: fb49f14f716cd9c5a5e0b9ca80c546adaaa7fcab15b8867965c7d823084f456580982270185c65861477c11840c219cbdf41fd676a597c2b39c4835ec7d0f5f4
data/README.md CHANGED
@@ -21,11 +21,17 @@ The server is a Rails engine mounted inside your application (by default at
21
21
  - **Queries use Ransack.** The `query` tool passes its arguments straight to
22
22
  [Ransack](https://activerecord-hackery.github.io/ransack/), the same search
23
23
  library ActiveAdmin uses for filtering.
24
+ - **Reads go through ActiveAdmin too.** `list_resources` and `query` run through
25
+ the same authorization adapter (CanCanCan, Pundit, etc.) as the authenticated
26
+ MCP user: resources the user cannot read are hidden from the listing and
27
+ refused by `query`, and every query is scoped with the adapter's
28
+ `scope_collection`, so the MCP user only ever sees the records they could see
29
+ in the admin UI. With ActiveAdmin's default adapter every check passes, so
30
+ applications without an authorization adapter are unaffected.
24
31
  - **Writes go through ActiveAdmin.** The `update` tool only writes fields
25
32
  allowed by the resource's `permit_params`, refuses resources that don't
26
33
  register the `update` action, and runs every change through your
27
- authorization adapter (CanCanCan, Pundit, etc.) as the authenticated MCP
28
- user.
34
+ authorization adapter as the authenticated MCP user.
29
35
  - **Authentication is optional but built in.** Enable Bearer-token auth and the
30
36
  installer adds an "MCP Tokens" management page to your ActiveAdmin panel.
31
37
 
@@ -57,8 +63,8 @@ read/query setup without authentication.
57
63
 
58
64
  | Tool | Description |
59
65
  |------|-------------|
60
- | `list_resources` | List every ActiveAdmin resource along with its attributes. |
61
- | `query` | Query a resource using Ransack syntax (`limit` defaults to 25, capped at 100). |
66
+ | `list_resources` | List the ActiveAdmin resources the current user may read, along with their attributes. |
67
+ | `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). |
62
68
  | `update` | Update an existing record, honouring ActiveAdmin's permitted params and authorization. |
63
69
 
64
70
  ### Query examples
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveadminMcp
4
+ # Applies a resource's ActiveAdmin authorization adapter to MCP tool calls, so
5
+ # reads, listings and writes obey the same rules as the admin UI.
6
+ #
7
+ # ActiveAdmin's default adapter authorizes every action and returns collections
8
+ # unchanged, so applications without an authorization adapter are unaffected.
9
+ # Applications that configure one (CanCanCan via `cancan_ability_class`, Pundit,
10
+ # a custom adapter, ...) get their policy enforced on every path.
11
+ class Authorization
12
+ READ = :read
13
+
14
+ def self.for(config, current_user)
15
+ adapter_class = config.namespace.authorization_adapter
16
+ adapter_class = adapter_class.constantize if adapter_class.is_a?(String)
17
+ new(adapter_class.new(config, current_user))
18
+ end
19
+
20
+ def initialize(adapter)
21
+ @adapter = adapter
22
+ end
23
+
24
+ def authorized?(action, subject)
25
+ @adapter.authorized?(action, subject)
26
+ end
27
+
28
+ def scope_collection(collection, action = READ)
29
+ @adapter.scope_collection(collection, action)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveadminMcp
4
+ # Records the field names declared by an ActiveAdmin `form do ... end` block.
5
+ #
6
+ # 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.
13
+ class FormFieldCollector
14
+ attr_reader :fields
15
+
16
+ def initialize
17
+ @fields = []
18
+ end
19
+
20
+ def collect(&block)
21
+ instance_exec(self, &block)
22
+ @fields.uniq
23
+ end
24
+
25
+ def input(name, *_args, **_opts, &_block)
26
+ @fields << name.to_sym if name.respond_to?(:to_sym)
27
+ self
28
+ end
29
+
30
+ def inputs(*_args, **_opts, &block)
31
+ instance_exec(self, &block) if block
32
+ self
33
+ end
34
+
35
+ def has_many(*_args, **_opts)
36
+ self
37
+ end
38
+
39
+ def method_missing(_name, *_args, **_opts, &block)
40
+ instance_exec(self, &block) if block
41
+ self
42
+ end
43
+
44
+ def respond_to_missing?(_name, _include_private = false)
45
+ true
46
+ end
47
+ end
48
+ end
@@ -53,29 +53,50 @@ module ActiveadminMcp
53
53
  end
54
54
 
55
55
  def authorized?(config, record)
56
- adapter_class = config.namespace.authorization_adapter
57
- adapter_class = adapter_class.constantize if adapter_class.is_a?(String)
58
- adapter_class.new(config, @current_user).authorized?(UPDATE, record)
56
+ Authorization.for(config, @current_user).authorized?(UPDATE, record)
59
57
  end
60
58
 
61
- # Runs the incoming attributes through the resource controller's own
62
- # permitted_params (compiled from `permit_params`), so we accept exactly what
63
- # the admin form accepts. Fails closed if that cannot be resolved.
59
+ # Resolves the fields we may write, accepting exactly what the admin form
60
+ # accepts. Prefers the resource's own `permit_params` (via the controller's
61
+ # compiled permitted_params); when a resource declares its writable fields
62
+ # through a `form do ... end` block instead — as ActiveAdmin's default
63
+ # permitted_params then returns nil — derives them from the form inputs.
64
+ # Fails closed if neither can be resolved.
64
65
  def permitted_attributes(config, attributes)
66
+ from_permit_params(config, attributes) ||
67
+ from_form(config, attributes) ||
68
+ raise(PermitError, "Could not determine permitted attributes: #{@resource[:name]}")
69
+ end
70
+
71
+ def from_permit_params(config, attributes)
65
72
  param_key = config.param_key.to_sym
66
73
  controller = config.controller.new
67
-
68
- unless controller.respond_to?(:permitted_params, true)
69
- raise PermitError, "Resource does not declare permit_params: #{@resource[:name]}"
70
- end
74
+ return nil unless controller.respond_to?(:permitted_params, true)
71
75
 
72
76
  controller.params = ActionController::Parameters.new(param_key => attributes)
73
- permitted = controller.send(:permitted_params)[param_key]
74
- permitted ? permitted.to_h.symbolize_keys : {}
75
- rescue PermitError
76
- raise
77
- rescue StandardError => e
78
- raise PermitError, "Could not determine permitted attributes: #{e.message}"
77
+ permitted = controller.send(:permitted_params)
78
+ scoped = permitted && permitted[param_key]
79
+ scoped ? scoped.to_h.symbolize_keys : nil
80
+ rescue StandardError
81
+ nil
82
+ end
83
+
84
+ def from_form(config, attributes)
85
+ fields = form_fields(config)
86
+ return nil if fields.empty?
87
+
88
+ ActionController::Parameters.new(attributes).permit(*fields).to_h.symbolize_keys
89
+ rescue StandardError
90
+ nil
91
+ end
92
+
93
+ def form_fields(config)
94
+ return [] unless config.respond_to?(:page_presenters)
95
+
96
+ block = config.page_presenters[:form]&.block
97
+ return [] unless block
98
+
99
+ FormFieldCollector.new.collect(&block)
79
100
  end
80
101
 
81
102
  def error(message, details: nil)
@@ -44,12 +44,15 @@ module ActiveadminMcp
44
44
  tools: [
45
45
  {
46
46
  name: "list_resources",
47
- description: "List all ActiveAdmin resources with their attributes",
47
+ description: "List the ActiveAdmin resources the authenticated user is authorized " \
48
+ "to read, with their attributes",
48
49
  inputSchema: { type: "object", properties: {} },
49
50
  },
50
51
  {
51
52
  name: "query",
52
- description: "Query an ActiveAdmin resource using Ransack syntax",
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.",
53
56
  inputSchema: {
54
57
  type: "object",
55
58
  properties: {
@@ -93,18 +96,21 @@ module ActiveadminMcp
93
96
  end
94
97
 
95
98
  def tool_list_resources
96
- { resources: ResourceRegistry.all }
99
+ entries = ResourceRegistry.resources.select { |entry| authorized_to_read?(entry) }
100
+ { resources: entries.map { |entry| ResourceRegistry.resource_info(entry) } }
97
101
  end
98
102
 
99
103
  def tool_query(args)
100
104
  resource = ResourceRegistry.find(args["resource"])
101
105
  return { error: "Resource not found: #{args['resource']}" } unless resource
106
+ return { error: "Not authorized to query #{resource[:name]}" } unless authorized_to_read?(resource)
102
107
 
103
108
  limit = [args["limit"] || 25, 100].min
104
109
  q = args["q"] || {}
105
110
 
106
- records = resource[:model].ransack(q).result.limit(limit)
107
- { resource: resource[:name], count: records.size, records: records.as_json }
111
+ relation = resource[:model].ransack(q).result
112
+ records = authorization(resource).scope_collection(relation, Authorization::READ).limit(limit)
113
+ { resource: resource[:name], count: records.size, records: filter_sensitive(records.as_json) }
108
114
  end
109
115
 
110
116
  def tool_update(args)
@@ -119,6 +125,21 @@ module ActiveadminMcp
119
125
  .call(id: args["id"], attributes: attributes)
120
126
  end
121
127
 
128
+ def authorized_to_read?(resource)
129
+ authorization(resource).authorized?(Authorization::READ, resource[:model])
130
+ end
131
+
132
+ def authorization(resource)
133
+ Authorization.for(resource[:config], @current_user)
134
+ end
135
+
136
+ def filter_sensitive(records)
137
+ sensitive = ResourceRegistry.sensitive_attributes
138
+ Array(records).map do |record|
139
+ record.is_a?(Hash) ? record.except(*sensitive) : record
140
+ end
141
+ end
142
+
122
143
  def success(id, result)
123
144
  { jsonrpc: "2.0", id: id, result: result }
124
145
  end
@@ -4,14 +4,28 @@ module ActiveadminMcp
4
4
  module ResourceRegistry
5
5
  class << self
6
6
  def all
7
- discover.map { |r| resource_info(r) }
7
+ resources.map { |entry| resource_info(entry) }
8
+ end
9
+
10
+ def resources
11
+ discover.map { |r| entry(r) }
8
12
  end
9
13
 
10
14
  def find(name)
11
- resource = discover.find { |r| r.resource_class.name == name }
12
- return unless resource
15
+ resources.find { |entry| entry[:name] == name }
16
+ end
13
17
 
14
- { name: resource.resource_class.name, model: resource.resource_class, config: resource }
18
+ def resource_info(entry)
19
+ klass = entry[:model]
20
+ {
21
+ name: klass.name,
22
+ table: klass.table_name,
23
+ attributes: klass.column_names - sensitive_attributes,
24
+ }
25
+ end
26
+
27
+ def sensitive_attributes
28
+ %w[encrypted_password password_digest reset_password_token api_key secret]
15
29
  end
16
30
 
17
31
  private
@@ -26,17 +40,8 @@ module ActiveadminMcp
26
40
  end || []
27
41
  end
28
42
 
29
- def resource_info(resource)
30
- klass = resource.resource_class
31
- {
32
- name: klass.name,
33
- table: klass.table_name,
34
- attributes: klass.column_names - sensitive_attributes,
35
- }
36
- end
37
-
38
- def sensitive_attributes
39
- %w[encrypted_password password_digest reset_password_token api_key secret]
43
+ def entry(resource)
44
+ { name: resource.resource_class.name, model: resource.resource_class, config: resource }
40
45
  end
41
46
  end
42
47
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveadminMcp
4
- VERSION = "0.0.1"
4
+ VERSION = "0.0.3"
5
5
  end
@@ -2,7 +2,9 @@
2
2
 
3
3
  require_relative "activeadmin_mcp/version"
4
4
  require_relative "activeadmin_mcp/configuration"
5
+ require_relative "activeadmin_mcp/authorization"
5
6
  require_relative "activeadmin_mcp/resource_registry"
7
+ require_relative "activeadmin_mcp/form_field_collector"
6
8
  require_relative "activeadmin_mcp/record_updater"
7
9
  require_relative "activeadmin_mcp/request_handler"
8
10
  require_relative "activeadmin_mcp/engine"
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.1
4
+ version: 0.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - harunkumars
@@ -95,8 +95,10 @@ 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/authorization.rb
98
99
  - lib/activeadmin_mcp/configuration.rb
99
100
  - lib/activeadmin_mcp/engine.rb
101
+ - lib/activeadmin_mcp/form_field_collector.rb
100
102
  - lib/activeadmin_mcp/record_updater.rb
101
103
  - lib/activeadmin_mcp/request_handler.rb
102
104
  - lib/activeadmin_mcp/resource_registry.rb