activeadmin_mcp 0.0.2 → 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: e336c562299eb5de7192243106f42dc18602589811fff5eb0282f12b605ea9b8
4
- data.tar.gz: 006f6aac09bceb97605517a9b1b7346a4aef6fc2ddc4c59ab48b36b658825078
3
+ metadata.gz: 1d97e042cb6e4af51f5939d4edd39a96f7193ece948a05a70dd8e18851f1bc85
4
+ data.tar.gz: 6b91b93d2d0511f3bea310f62044589eb35b90358547e942d379be214582d5e7
5
5
  SHA512:
6
- metadata.gz: 602940dbd9eb2a0337220672258f343aff84d0851eaec4b0199b38cb99f843adca48ee085ab201ed896ec29f260fe5e8ae6a8844b567990611e4c866d3abd05f
7
- data.tar.gz: 54e2309b725add92b02af4ebbaefdafd5cfa8c349890211c22f0bb391f00863764e89cf50f7e5f6ace5f293962f473ad75f5197f7813702b939cfc2dc95d5b9d
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
@@ -53,9 +53,7 @@ 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
59
  # Resolves the fields we may write, accepting exactly what the admin form
@@ -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.2"
4
+ VERSION = "0.0.3"
5
5
  end
@@ -2,6 +2,7 @@
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"
6
7
  require_relative "activeadmin_mcp/form_field_collector"
7
8
  require_relative "activeadmin_mcp/record_updater"
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.2
4
+ version: 0.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - harunkumars
@@ -95,6 +95,7 @@ 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
100
101
  - lib/activeadmin_mcp/form_field_collector.rb