activeadmin_mcp 0.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 99312ac0bef360e80a8d058b79adf5f0af182d3bedc19fdfbf4caa69964f7e44
4
+ data.tar.gz: bba982b59472e271c18214bc4674e60a3ae96729b78172163d54eed756b3997c
5
+ SHA512:
6
+ metadata.gz: 861ea8e3d8eeff901f12f4fa99385db60fd0854a0eae22462cf314cce67820632033b8e55ebc13252bcaa3389f0b6aceda4cc712d5bceb4ba0c342ac10a5815d
7
+ data.tar.gz: 035b92456d5b3ca111c5e4715c8f495f52531ab053eb7974f1c1dda12de73d87f08ba34b40f226723d8a207bc98a89c929c4d6bb8ecdc9ee0873fdc6fbefb275
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 harunkumars
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,276 @@
1
+ # ActiveadminMcp
2
+
3
+ > **Status: Experimental / work in progress**
4
+
5
+ `activeadmin_mcp` turns the resources you have already registered with
6
+ [ActiveAdmin](https://activeadmin.info/) into a
7
+ [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server, so AI
8
+ assistants such as Claude Code can list, query, and update your admin data —
9
+ while respecting the exact same forms, permitted parameters, and authorization
10
+ rules as your ActiveAdmin UI.
11
+
12
+ The server is a Rails engine mounted inside your application (by default at
13
+ `/mcp`) and speaks MCP over HTTP (JSON-RPC 2.0, protocol revision
14
+ `2025-06-18`).
15
+
16
+ ## How it works
17
+
18
+ - **Nothing new to describe.** The engine reads your existing ActiveAdmin
19
+ registrations, so the resources, attributes, and permitted fields it exposes
20
+ are the ones you have already configured.
21
+ - **Queries use Ransack.** The `query` tool passes its arguments straight to
22
+ [Ransack](https://activerecord-hackery.github.io/ransack/), the same search
23
+ library ActiveAdmin uses for filtering.
24
+ - **Writes go through ActiveAdmin.** The `update` tool only writes fields
25
+ allowed by the resource's `permit_params`, refuses resources that don't
26
+ register the `update` action, and runs every change through your
27
+ authorization adapter (CanCanCan, Pundit, etc.) as the authenticated MCP
28
+ user.
29
+ - **Authentication is optional but built in.** Enable Bearer-token auth and the
30
+ installer adds an "MCP Tokens" management page to your ActiveAdmin panel.
31
+
32
+ ## Requirements
33
+
34
+ - Ruby >= 3.0
35
+ - Rails >= 6.1
36
+ - ActiveAdmin >= 2.0
37
+
38
+ ## Installation
39
+
40
+ Add the gem to your Gemfile:
41
+
42
+ ```ruby
43
+ gem "activeadmin_mcp"
44
+ ```
45
+
46
+ Install it and run the generator:
47
+
48
+ ```bash
49
+ bundle install
50
+ rails generate activeadmin_mcp:install
51
+ ```
52
+
53
+ The MCP server is mounted at `/mcp` automatically. That's all you need for a
54
+ read/query setup without authentication.
55
+
56
+ ## Available tools
57
+
58
+ | Tool | Description |
59
+ |------|-------------|
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). |
62
+ | `update` | Update an existing record, honouring ActiveAdmin's permitted params and authorization. |
63
+
64
+ ### Query examples
65
+
66
+ ```
67
+ Query users whose email contains "example.com"
68
+ → query(resource: "User", q: { email_cont: "example.com" })
69
+
70
+ Find active posts created since the start of the month
71
+ → query(resource: "Post", q: { status_eq: "active", created_at_gt: "2026-08-01" })
72
+ ```
73
+
74
+ ### Updating records
75
+
76
+ ```
77
+ Update a user's name
78
+ → update(resource: "User", id: 42, attributes: { name: "New name" })
79
+ ```
80
+
81
+ The `update` tool applies the same rules as the ActiveAdmin UI:
82
+
83
+ - **Editable resources only** — resources registered without the `update`
84
+ action (e.g. `actions :index, :show`) are refused.
85
+ - **Authorization** — the change runs through the resource namespace's
86
+ authorization adapter for the authenticated MCP user, so it can only update
87
+ what that user is allowed to update in admin.
88
+ - **Permitted fields only** — attributes are filtered through the resource's
89
+ `permit_params`; fields the admin form doesn't accept are silently dropped.
90
+
91
+ ## Connecting a client
92
+
93
+ `activeadmin_mcp` has been tested with **Claude Code** (Anthropic) over the
94
+ HTTP transport.
95
+
96
+ ```bash
97
+ claude mcp add --transport http my-app http://localhost:3000/mcp/
98
+ ```
99
+
100
+ Or add it to your `.mcp.json`:
101
+
102
+ ```json
103
+ {
104
+ "mcpServers": {
105
+ "my-app": {
106
+ "type": "http",
107
+ "url": "http://localhost:3000/mcp/"
108
+ }
109
+ }
110
+ }
111
+ ```
112
+
113
+ ## Authentication
114
+
115
+ To protect the MCP endpoint with API-token authentication, run the installer
116
+ with the `devise_token` strategy and migrate:
117
+
118
+ ```bash
119
+ rails generate activeadmin_mcp:install --auth devise_token
120
+ rails db:migrate
121
+ ```
122
+
123
+ This will:
124
+
125
+ - Create the `mcp_api_tokens` table.
126
+ - Add an "MCP Tokens" page to your ActiveAdmin panel (`app/admin/` by default).
127
+ - Enable token authentication in the initializer.
128
+
129
+ ### Generator options
130
+
131
+ | Option | Default | Description |
132
+ |--------|---------|-------------|
133
+ | `--auth` | none | Authentication method to use (e.g. `devise_token`). |
134
+ | `--admin-path` | `app/admin` | Directory for the ActiveAdmin page file. |
135
+
136
+ Example with a custom admin path:
137
+
138
+ ```bash
139
+ rails generate activeadmin_mcp:install --auth devise_token --admin-path app/admin/mcp
140
+ ```
141
+
142
+ ### Managing tokens
143
+
144
+ 1. Log in to your ActiveAdmin panel (`/admin`).
145
+ 2. Navigate to **MCP Tokens** (or **Settings > MCP Tokens** if you set a
146
+ `menu_parent`).
147
+ 3. Create a token and copy it — it is only shown once.
148
+
149
+ ### Connecting with a token
150
+
151
+ ```bash
152
+ claude mcp add --transport http \
153
+ --header 'Authorization: Bearer YOUR_TOKEN' \
154
+ my-app http://localhost:3000/mcp/
155
+ ```
156
+
157
+ Or in `.mcp.json`:
158
+
159
+ ```json
160
+ {
161
+ "mcpServers": {
162
+ "my-app": {
163
+ "type": "http",
164
+ "url": "http://localhost:3000/mcp/",
165
+ "headers": {
166
+ "Authorization": "Bearer YOUR_TOKEN"
167
+ }
168
+ }
169
+ }
170
+ }
171
+ ```
172
+
173
+ ### Custom auth header
174
+
175
+ If your application sits behind a reverse proxy that strips the standard
176
+ `Authorization` header (e.g. AWS Verified Access), configure a custom header
177
+ name and pass the token through it instead:
178
+
179
+ ```ruby
180
+ ActiveadminMcp.configure do |config|
181
+ config.authentication_method = :devise_token
182
+ config.auth_header_name = "X-MCP-Authorization"
183
+ end
184
+ ```
185
+
186
+ ```json
187
+ {
188
+ "mcpServers": {
189
+ "my-app": {
190
+ "type": "http",
191
+ "url": "https://admin.example.com/admin/mcp/",
192
+ "headers": {
193
+ "X-MCP-Authorization": "Bearer YOUR_TOKEN"
194
+ }
195
+ }
196
+ }
197
+ }
198
+ ```
199
+
200
+ ## Configuration
201
+
202
+ The generator writes an initializer to
203
+ `config/initializers/activeadmin_mcp.rb`:
204
+
205
+ ```ruby
206
+ ActiveadminMcp.configure do |config|
207
+ config.authentication_method = :devise_token
208
+ config.user_class = "User" # your Devise model class
209
+ end
210
+ ```
211
+
212
+ | Option | Default | Description |
213
+ |--------|---------|-------------|
214
+ | `authentication_method` | `nil` | Set to `:devise_token` to enable Bearer-token auth. |
215
+ | `user_class` | `"User"` | The Devise model class name. |
216
+ | `current_user_method` | `:current_admin_user` | Controller method returning the current user. |
217
+ | `menu_parent` | `nil` | Parent menu for the MCP Tokens page (e.g. `"Settings"`). |
218
+ | `mount_path` | `"/mcp"` | Path where the MCP server is mounted. |
219
+ | `mount_strategy` | `:prepend` | Route mounting strategy: `:prepend`, `:append`, or `:none`. |
220
+ | `auth_header_name` | `"Authorization"` | HTTP header to read the Bearer token from. |
221
+
222
+ ### Route mounting
223
+
224
+ By default the engine prepends its route to the top of your application's route
225
+ table. This suits most setups, but can cause problems when your admin routes
226
+ use constraints (e.g. hostname-based routing), because a prepended mount sits
227
+ outside any constraint blocks.
228
+
229
+ | Strategy | Behaviour |
230
+ |----------|-----------|
231
+ | `:prepend` | **(default)** Mounts at the top of the route table via `routes.prepend`. |
232
+ | `:append` | Mounts at the bottom of the route table via `routes.append`. |
233
+ | `:none` | Skips automatic mounting — you mount the engine yourself. |
234
+
235
+ To mount inside a constraint block, set `mount_strategy` to `:none` and mount
236
+ the engine manually:
237
+
238
+ ```ruby
239
+ # config/initializers/activeadmin_mcp.rb
240
+ ActiveadminMcp.configure do |config|
241
+ config.mount_path = "/admin/mcp"
242
+ config.mount_strategy = :none
243
+ end
244
+ ```
245
+
246
+ ```ruby
247
+ # config/routes.rb (or a drawn route file)
248
+ constraints AdminConstraint.new do
249
+ ActiveAdmin.routes(self)
250
+ mount ActiveadminMcp::Engine => ActiveadminMcp.config.mount_path
251
+ end
252
+ ```
253
+
254
+ ## Development
255
+
256
+ After checking out the repo, install dependencies and run the test suite:
257
+
258
+ ```bash
259
+ bundle install
260
+ bundle exec rspec
261
+ ```
262
+
263
+ ## Contributing
264
+
265
+ Bug reports and pull requests are welcome on GitHub.
266
+
267
+ ## Credits
268
+
269
+ This project was forked from
270
+ [betacraft/active_admin_mcp](https://github.com/betacraft/active_admin_mcp),
271
+ originally created by [harunkumars](https://github.com/harunkumars), and has
272
+ been extended from there.
273
+
274
+ ## License
275
+
276
+ Released under the [MIT License](LICENSE.txt).
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveadminMcp
4
+ class McpController < ActionController::API
5
+ before_action :authenticate_mcp_token!
6
+
7
+ attr_reader :current_mcp_user
8
+
9
+ def call
10
+ request_body = JSON.parse(request.body.read)
11
+ response = RequestHandler.new(current_user: current_mcp_user).handle(request_body)
12
+
13
+ response ? render(json: response) : head(:no_content)
14
+ rescue JSON::ParserError => e
15
+ render json: { jsonrpc: "2.0", error: { code: -32_700, message: e.message } }, status: :bad_request
16
+ end
17
+
18
+ private
19
+
20
+ def authenticate_mcp_token!
21
+ return unless ActiveadminMcp.config.authentication_enabled?
22
+
23
+ token = extract_bearer_token
24
+ unless token
25
+ render json: jsonrpc_error(-32_000, "Unauthorized"), status: :unauthorized
26
+ return
27
+ end
28
+
29
+ api_token = ApiToken.find_by_raw_token(token)
30
+ unless api_token
31
+ render json: jsonrpc_error(-32_000, "Unauthorized"), status: :unauthorized
32
+ return
33
+ end
34
+
35
+ @current_mcp_user = api_token.user
36
+ api_token.touch_last_used!
37
+ rescue ActiveRecord::StatementInvalid
38
+ render json: jsonrpc_error(-32_000, "Authentication not configured — run migrations"),
39
+ status: :internal_server_error
40
+ end
41
+
42
+ def extract_bearer_token
43
+ header = request.headers[ActiveadminMcp.config.auth_header_name]
44
+ return nil unless header&.start_with?("Bearer ")
45
+
46
+ header.delete_prefix("Bearer ")
47
+ end
48
+
49
+ def jsonrpc_error(code, message)
50
+ { jsonrpc: "2.0", id: nil, error: { code: code, message: message } }
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "securerandom"
5
+
6
+ module ActiveadminMcp
7
+ class ApiToken < ActiveRecord::Base
8
+ self.table_name = "mcp_api_tokens"
9
+
10
+ belongs_to :user, class_name: ActiveadminMcp.config.user_class
11
+
12
+ attr_accessor :raw_token
13
+
14
+ validates :token_digest, presence: true, uniqueness: true
15
+ validates :user_id, presence: true
16
+
17
+ before_validation :generate_token, on: :create
18
+
19
+ LAST_USED_THROTTLE = 5.minutes
20
+
21
+ def self.find_by_raw_token(raw_token)
22
+ return nil if raw_token.blank?
23
+
24
+ find_by(token_digest: digest(raw_token))
25
+ end
26
+
27
+ def self.digest(raw_token)
28
+ Digest::SHA256.hexdigest(raw_token)
29
+ end
30
+
31
+ def touch_last_used!
32
+ return if last_used_at.present? && last_used_at > LAST_USED_THROTTLE.ago
33
+
34
+ update_column(:last_used_at, Time.current)
35
+ end
36
+
37
+ private
38
+
39
+ def generate_token
40
+ self.raw_token = "aamcp_#{SecureRandom.hex(32)}"
41
+ self.token_digest = self.class.digest(raw_token)
42
+ end
43
+ end
44
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActiveadminMcp::Engine.routes.draw do
4
+ post "/", to: "mcp#call"
5
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveadminMcp
4
+ class Configuration
5
+ MOUNT_STRATEGIES = %i[prepend append none].freeze
6
+
7
+ attr_accessor :authentication_method, :user_class, :current_user_method, :menu_parent, :mount_path,
8
+ :auth_header_name
9
+
10
+ attr_reader :mount_strategy
11
+
12
+ def initialize
13
+ @authentication_method = nil
14
+ @user_class = "User"
15
+ @current_user_method = :current_admin_user
16
+ @menu_parent = nil
17
+ @mount_path = "/mcp"
18
+ @mount_strategy = :prepend
19
+ @auth_header_name = "Authorization"
20
+ end
21
+
22
+ def mount_strategy=(strategy)
23
+ unless MOUNT_STRATEGIES.include?(strategy)
24
+ raise ArgumentError, "Invalid mount strategy: #{strategy}. Must be one of: #{MOUNT_STRATEGIES.join(', ')}"
25
+ end
26
+
27
+ @mount_strategy = strategy
28
+ end
29
+
30
+ def authentication_enabled?
31
+ authentication_method == :devise_token
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveadminMcp
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace ActiveadminMcp
6
+
7
+ initializer "activeadmin_mcp.mount" do |app|
8
+ case ActiveadminMcp.config.mount_strategy
9
+ when :prepend
10
+ app.routes.prepend do
11
+ mount ActiveadminMcp::Engine => ActiveadminMcp.config.mount_path
12
+ end
13
+ when :append
14
+ app.routes.append do
15
+ mount ActiveadminMcp::Engine => ActiveadminMcp.config.mount_path
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveadminMcp
4
+ # Updates a single ActiveAdmin-managed record, enforcing the same three gates
5
+ # the admin UI would: the resource must expose the update action, the current
6
+ # user must be authorized, and only fields the admin form permits are written.
7
+ class RecordUpdater
8
+ UPDATE = :update
9
+
10
+ # Raised internally when the resource's permitted params cannot be resolved.
11
+ class PermitError < StandardError; end
12
+
13
+ def initialize(resource:, current_user:)
14
+ @resource = resource
15
+ @current_user = current_user
16
+ end
17
+
18
+ def call(id:, attributes:)
19
+ config = @resource[:config]
20
+
21
+ return error("Resource is not editable: #{@resource[:name]}") unless editable?(config)
22
+
23
+ record = @resource[:model].find_by(id: id)
24
+ return error("Record not found: #{@resource[:name]}##{id}") unless record
25
+
26
+ unless authorized?(config, record)
27
+ return error("Not authorized to update #{@resource[:name]}##{id}")
28
+ end
29
+
30
+ begin
31
+ permitted = permitted_attributes(config, attributes)
32
+ rescue PermitError => e
33
+ return error(e.message)
34
+ end
35
+ return error("No permitted attributes to update") if permitted.empty?
36
+
37
+ if record.update(permitted)
38
+ {
39
+ resource: @resource[:name],
40
+ id: record.id,
41
+ updated: permitted.keys,
42
+ record: record.as_json,
43
+ }
44
+ else
45
+ error("Validation failed", details: record.errors.full_messages)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def editable?(config)
52
+ config.defined_actions.include?(UPDATE)
53
+ end
54
+
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)
59
+ end
60
+
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.
64
+ def permitted_attributes(config, attributes)
65
+ param_key = config.param_key.to_sym
66
+ 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
71
+
72
+ 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}"
79
+ end
80
+
81
+ def error(message, details: nil)
82
+ result = { error: message }
83
+ result[:details] = details if details
84
+ result
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveadminMcp
4
+ class RequestHandler
5
+ PROTOCOL_VERSION = "2025-06-18"
6
+
7
+ def initialize(current_user: nil)
8
+ @current_user = current_user
9
+ end
10
+
11
+ def handle(request)
12
+ id = request["id"]
13
+ method = request["method"]
14
+ params = request["params"] || {}
15
+
16
+ case method
17
+ when "initialize"
18
+ success(id, initialize_result)
19
+ when "notifications/initialized"
20
+ nil
21
+ when "tools/list"
22
+ success(id, tools_list)
23
+ when "tools/call"
24
+ success(id, call_tool(params))
25
+ when "ping"
26
+ success(id, {})
27
+ else
28
+ error(id, -32_601, "Method not found: #{method}")
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def initialize_result
35
+ {
36
+ protocolVersion: PROTOCOL_VERSION,
37
+ serverInfo: { name: "activeadmin-mcp", version: ActiveadminMcp::VERSION },
38
+ capabilities: { tools: {} },
39
+ }
40
+ end
41
+
42
+ def tools_list
43
+ {
44
+ tools: [
45
+ {
46
+ name: "list_resources",
47
+ description: "List all ActiveAdmin resources with their attributes",
48
+ inputSchema: { type: "object", properties: {} },
49
+ },
50
+ {
51
+ name: "query",
52
+ description: "Query an ActiveAdmin resource using Ransack syntax",
53
+ inputSchema: {
54
+ type: "object",
55
+ properties: {
56
+ resource: { type: "string", description: "Resource name (e.g., 'User', 'Post')" },
57
+ q: { type: "object", description: "Ransack query (e.g., {name_cont: 'john'})" },
58
+ limit: { type: "integer", description: "Max records (default: 25)" },
59
+ },
60
+ required: ["resource"],
61
+ },
62
+ },
63
+ {
64
+ name: "update",
65
+ description: "Update an existing record. Only fields the resource's ActiveAdmin " \
66
+ "form permits are written, and the update respects ActiveAdmin authorization.",
67
+ inputSchema: {
68
+ type: "object",
69
+ properties: {
70
+ resource: { type: "string", description: "Resource name (e.g., 'User', 'Post')" },
71
+ id: { type: ["integer", "string"], description: "Primary key of the record to update" },
72
+ attributes: { type: "object", description: "Attributes to update (e.g., {name: 'New name'})" },
73
+ },
74
+ required: %w[resource id attributes],
75
+ },
76
+ },
77
+ ],
78
+ }
79
+ end
80
+
81
+ def call_tool(params)
82
+ name = params["name"]
83
+ args = params["arguments"] || {}
84
+
85
+ result = case name
86
+ when "list_resources" then tool_list_resources
87
+ when "query" then tool_query(args)
88
+ when "update" then tool_update(args)
89
+ else { error: "Unknown tool: #{name}" }
90
+ end
91
+
92
+ { content: [{ type: "text", text: JSON.pretty_generate(result) }] }
93
+ end
94
+
95
+ def tool_list_resources
96
+ { resources: ResourceRegistry.all }
97
+ end
98
+
99
+ def tool_query(args)
100
+ resource = ResourceRegistry.find(args["resource"])
101
+ return { error: "Resource not found: #{args['resource']}" } unless resource
102
+
103
+ limit = [args["limit"] || 25, 100].min
104
+ q = args["q"] || {}
105
+
106
+ records = resource[:model].ransack(q).result.limit(limit)
107
+ { resource: resource[:name], count: records.size, records: records.as_json }
108
+ end
109
+
110
+ def tool_update(args)
111
+ resource = ResourceRegistry.find(args["resource"])
112
+ return { error: "Resource not found: #{args['resource']}" } unless resource
113
+ return { error: "id is required" } if args["id"].nil?
114
+
115
+ attributes = args["attributes"] || {}
116
+ return { error: "attributes are required" } if attributes.empty?
117
+
118
+ RecordUpdater.new(resource: resource, current_user: @current_user)
119
+ .call(id: args["id"], attributes: attributes)
120
+ end
121
+
122
+ def success(id, result)
123
+ { jsonrpc: "2.0", id: id, result: result }
124
+ end
125
+
126
+ def error(id, code, message)
127
+ { jsonrpc: "2.0", id: id, error: { code: code, message: message } }
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveadminMcp
4
+ module ResourceRegistry
5
+ class << self
6
+ def all
7
+ discover.map { |r| resource_info(r) }
8
+ end
9
+
10
+ def find(name)
11
+ resource = discover.find { |r| r.resource_class.name == name }
12
+ return unless resource
13
+
14
+ { name: resource.resource_class.name, model: resource.resource_class, config: resource }
15
+ end
16
+
17
+ private
18
+
19
+ def discover
20
+ return [] unless defined?(ActiveAdmin)
21
+
22
+ ActiveAdmin.application.namespaces[:admin]&.resources&.select do |r|
23
+ r.respond_to?(:resource_class) &&
24
+ r.resource_class.respond_to?(:ransack) &&
25
+ r.resource_class.table_exists?
26
+ end || []
27
+ end
28
+
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]
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveadminMcp
4
+ VERSION = "0.0.1"
5
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "activeadmin_mcp/version"
4
+ require_relative "activeadmin_mcp/configuration"
5
+ require_relative "activeadmin_mcp/resource_registry"
6
+ require_relative "activeadmin_mcp/record_updater"
7
+ require_relative "activeadmin_mcp/request_handler"
8
+ require_relative "activeadmin_mcp/engine"
9
+
10
+ module ActiveadminMcp
11
+ class Error < StandardError; end
12
+
13
+ class << self
14
+ def config
15
+ @config ||= Configuration.new
16
+ end
17
+
18
+ def configure
19
+ yield config
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module ActiveadminMcp
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ class_option :auth, type: :string, default: nil,
14
+ desc: "Authentication method (e.g., devise_token)"
15
+ class_option :admin_path, type: :string, default: "app/admin",
16
+ desc: "Path for ActiveAdmin page file"
17
+
18
+ def copy_initializer
19
+ template "initializer.rb", "config/initializers/activeadmin_mcp.rb"
20
+ end
21
+
22
+ def copy_migration
23
+ return unless auth_method
24
+
25
+ migration_template "migration.rb.erb", "db/migrate/create_mcp_api_tokens.rb"
26
+ end
27
+
28
+ def copy_admin_page
29
+ return unless auth_method
30
+
31
+ copy_file "mcp_api_tokens.rb", File.join(options[:admin_path], "mcp_api_tokens.rb")
32
+ end
33
+
34
+ def set_auth_config
35
+ return unless auth_method
36
+
37
+ gsub_file "config/initializers/activeadmin_mcp.rb",
38
+ "# config.authentication_method = :devise_token",
39
+ "config.authentication_method = :#{auth_method}"
40
+ end
41
+
42
+ def show_instructions
43
+ say ""
44
+ say "=" * 60, :green
45
+ say " ActiveadminMcp installed!", :green
46
+ say "=" * 60, :green
47
+ say ""
48
+ say "Your MCP server is available at: /mcp"
49
+ say ""
50
+
51
+ if auth_method
52
+ say "Authentication (#{auth_method}) enabled! Next steps:", :yellow
53
+ say ""
54
+ say " 1. Run migrations:"
55
+ say " rails db:migrate"
56
+ say ""
57
+ say " 2. Create tokens via ActiveAdmin:"
58
+ say " Log in to /admin and visit 'MCP Tokens' under Settings"
59
+ say ""
60
+ say " 3. Connect Claude Code with your token:"
61
+ say " claude mcp add --transport http \\", :cyan
62
+ say " --header 'Authorization: Bearer YOUR_TOKEN' \\", :cyan
63
+ say " #{app_name} http://localhost:3000/mcp/", :cyan
64
+ else
65
+ say "Connect Claude Code:"
66
+ say ""
67
+ say " claude mcp add --transport http #{app_name} http://localhost:3000/mcp/"
68
+ say ""
69
+ say "To add authentication later:"
70
+ say " rails generate activeadmin_mcp:install --auth devise_token"
71
+ end
72
+
73
+ say ""
74
+ end
75
+
76
+ private
77
+
78
+ def auth_method
79
+ options[:auth]
80
+ end
81
+
82
+ def app_name
83
+ Rails.application.class.module_parent_name.underscore.dasherize
84
+ rescue StandardError
85
+ "my-app"
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActiveadminMcp.configure do |config|
4
+ # Uncomment to enable API token authentication.
5
+ # Requires running the auth migration first:
6
+ # rails generate activeadmin_mcp:install --auth
7
+ #
8
+ # config.authentication_method = :devise_token
9
+
10
+ # The Devise model class used for authentication.
11
+ # config.user_class = "User"
12
+
13
+ # The controller method that returns the current user.
14
+ # config.current_user_method = :current_admin_user
15
+
16
+ # Parent menu for the MCP Tokens page in ActiveAdmin.
17
+ # config.menu_parent = "Settings"
18
+
19
+ # Path where the MCP server is mounted.
20
+ # config.mount_path = "/mcp"
21
+
22
+ # HTTP header used to read the Bearer token from.
23
+ # Useful when a reverse proxy (e.g. AWS Verified Access) strips the
24
+ # standard Authorization header.
25
+ # config.auth_header_name = "Authorization"
26
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActiveAdmin.register_page "MCP API Tokens" do
4
+ menu label: "MCP Tokens", parent: ActiveadminMcp.config.menu_parent, priority: 100
5
+
6
+ content do
7
+ @tokens = ActiveadminMcp::ApiToken.where(user: send(ActiveadminMcp.config.current_user_method)).order(created_at: :desc)
8
+
9
+ if flash[:mcp_raw_token]
10
+ panel "New Token Created", class: "mcp-token-created" do
11
+ para "Copy this token now — it will not be shown again:"
12
+ pre flash[:mcp_raw_token], class: "mcp-raw-token"
13
+ end
14
+ end
15
+
16
+ panel "Your MCP API Tokens" do
17
+ table_for @tokens do
18
+ column :name
19
+ column(:created_at) { |t| l(t.created_at, format: :long) }
20
+ column(:last_used_at) { |t| t.last_used_at ? l(t.last_used_at, format: :long) : "Never" }
21
+ column "Actions" do |token|
22
+ link_to "Revoke", admin_mcp_api_tokens_destroy_path(token_id: token.id),
23
+ method: :delete,
24
+ data: { confirm: "Revoke token '#{token.name}'?" },
25
+ class: "button small"
26
+ end
27
+ end
28
+
29
+ if @tokens.empty?
30
+ para "No tokens yet. Create one to authenticate MCP clients."
31
+ end
32
+ end
33
+
34
+ panel "Create New Token" do
35
+ form action: admin_mcp_api_tokens_create_path, method: :post do
36
+ input type: :hidden, name: :authenticity_token, value: form_authenticity_token
37
+ label "Token Name", for: :mcp_token_name
38
+ input type: :text, name: "mcp_token[name]", id: :mcp_token_name, placeholder: "e.g., Claude Code laptop"
39
+ input type: :submit, value: "Generate Token"
40
+ end
41
+ end
42
+ end
43
+
44
+ page_action :create, method: :post do
45
+ token = ActiveadminMcp::ApiToken.create!(
46
+ user: send(ActiveadminMcp.config.current_user_method),
47
+ name: params[:mcp_token][:name].presence || "Unnamed token"
48
+ )
49
+ flash[:mcp_raw_token] = token.raw_token
50
+ redirect_to admin_mcp_api_tokens_path()
51
+ end
52
+
53
+ page_action :destroy, method: :delete do
54
+ token = ActiveadminMcp::ApiToken.where(user: send(ActiveadminMcp.config.current_user_method)).find(params[:token_id])
55
+ token.destroy!
56
+ flash[:notice] = "Token revoked."
57
+ redirect_to admin_mcp_api_tokens_path()
58
+ end
59
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateMcpApiTokens < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def change
5
+ return if table_exists?(:mcp_api_tokens)
6
+
7
+ create_table :mcp_api_tokens do |t|
8
+ t.references :user, null: false
9
+ t.string :token_digest, null: false
10
+ t.string :name
11
+ t.datetime :last_used_at
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :mcp_api_tokens, :token_digest, unique: true
17
+ end
18
+ end
metadata ADDED
@@ -0,0 +1,133 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activeadmin_mcp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - harunkumars
8
+ - OLIOEX
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 1980-01-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '6.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '6.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activeadmin
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: sqlite3
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '1.4'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '1.4'
83
+ description: Expose your ActiveAdmin resources to AI assistants via the Model Context
84
+ Protocol (MCP).
85
+ email:
86
+ - harun@betacraft.io
87
+ - lloyd@olioex.com
88
+ executables: []
89
+ extensions: []
90
+ extra_rdoc_files: []
91
+ files:
92
+ - LICENSE.txt
93
+ - README.md
94
+ - app/controllers/activeadmin_mcp/mcp_controller.rb
95
+ - app/models/activeadmin_mcp/api_token.rb
96
+ - config/routes.rb
97
+ - lib/activeadmin_mcp.rb
98
+ - lib/activeadmin_mcp/configuration.rb
99
+ - lib/activeadmin_mcp/engine.rb
100
+ - lib/activeadmin_mcp/record_updater.rb
101
+ - lib/activeadmin_mcp/request_handler.rb
102
+ - lib/activeadmin_mcp/resource_registry.rb
103
+ - lib/activeadmin_mcp/version.rb
104
+ - lib/generators/activeadmin_mcp/install/install_generator.rb
105
+ - lib/generators/activeadmin_mcp/install/templates/initializer.rb
106
+ - lib/generators/activeadmin_mcp/install/templates/mcp_api_tokens.rb
107
+ - lib/generators/activeadmin_mcp/install/templates/migration.rb.erb
108
+ homepage: https://github.com/OLIOEX/activeadmin_mcp
109
+ licenses:
110
+ - MIT
111
+ metadata:
112
+ homepage_uri: https://github.com/OLIOEX/activeadmin_mcp
113
+ source_code_uri: https://github.com/OLIOEX/activeadmin_mcp
114
+ changelog_uri: https://github.com/OLIOEX/activeadmin_mcp/blob/main/CHANGELOG.md
115
+ rubygems_mfa_required: 'true'
116
+ rdoc_options: []
117
+ require_paths:
118
+ - lib
119
+ required_ruby_version: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: 3.0.0
124
+ required_rubygems_version: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ version: '0'
129
+ requirements: []
130
+ rubygems_version: 3.6.9
131
+ specification_version: 4
132
+ summary: MCP server for Rails apps with ActiveAdmin
133
+ test_files: []