Skip to content

Repository files navigation

ActiveadminMcp

Status: Experimental / work in progress

activeadmin_mcp turns the resources you have already registered with ActiveAdmin into a Model Context Protocol (MCP) server, so AI assistants such as Claude Code can list, query, and update your admin data — while respecting the exact same forms, permitted parameters, and authorization rules as your ActiveAdmin UI.

The server is a Rails engine mounted inside your application (by default at /mcp) and speaks MCP over HTTP (JSON-RPC 2.0, protocol revision 2025-06-18).

How it works

  • Nothing new to describe. The engine reads your existing ActiveAdmin registrations, so the resources, attributes, and permitted fields it exposes are the ones you have already configured.
  • Queries use Ransack. The query tool passes its arguments straight to Ransack, the same search library ActiveAdmin uses for filtering. The tool calls ransack on the model directly rather than going through an ActiveAdmin filter form, so on Ransack 4 the model must allowlist the attributes it wants queryable via ransackable_attributes; without that allowlist, query against that resource will raise instead of returning an empty result.
  • Reads go through ActiveAdmin too. list_resources and query run through the same authorization adapter (CanCanCan, Pundit, etc.) as the authenticated MCP user: resources the user cannot read are hidden from the listing and refused by query, and every query is scoped with the adapter's scope_collection, so the MCP user only ever sees the records they could see in the admin UI. With ActiveAdmin's default adapter every check passes, so applications without an authorization adapter are unaffected.
  • Writes go through ActiveAdmin. The create and update tools dispatch the resource's real ActiveAdmin controller action, so permit_params, your before_save/after_update callbacks, the controller's before_action chain and your authorization adapter all apply exactly as they do when someone clicks Save in the admin UI. Resources that don't register the action are refused, and describe_form will tell a client what a given resource's form accepts before it tries.
  • Authentication is optional but built in. Enable Bearer-token auth and the installer adds an "MCP Tokens" management page to your ActiveAdmin panel.

Requirements

  • Ruby >= 4.0
  • Rails >= 7.2
  • ActiveAdmin ~> 3.5

Installation

Add the gem to your Gemfile:

gem "activeadmin_mcp"

Install it and run the generator:

bundle install
rails generate activeadmin_mcp:install

The MCP server is mounted at /mcp automatically. That's all you need for a read/query setup without authentication.

Available tools

Tool Description
list_resources List the ActiveAdmin resources the current user may read, along with their attributes.
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).
create Create a new record through the resource's ActiveAdmin create action, honouring its permitted params, callbacks and authorization.
update Update an existing record through the resource's ActiveAdmin update action, honouring its permitted params, callbacks and authorization.
describe_form Describe the fields of a resource's form — input types, labels, hints, allowed values, column types and which are required — so a create or update call need not guess them.
(per action) Any ActiveAdmin member, collection or batch action the application has opted in with an mcp: option, exposed as its own tool.

The examples below are drawn from the application the end-to-end suite builds and drives over HTTP, which is checked in under spec/e2e/fixture_app/: Post permits title and body but not slug, Author is registered actions :index, :show, Review declares its own form block, and Tag declares no permit_params at all. Most of what this README claims is asserted against that running application in spec/e2e/, so a claim here can be read next to the example that exercises it.

Query examples

Find the post whose title mentions Earthsea
→ query(resource: "Post", q: { title_cont: "Earthsea" })

Find draft posts created since the start of the month
→ query(resource: "Post", q: { status_eq: "draft", created_at_gt: "2026-08-01" })

Look an author up by email
→ query(resource: "Author", q: { email_eq: "ursula@example.com" })

Creating and updating records

Create a post
→ create(resource: "Post", attributes: { title: "Mort", body: "The fourth." })

Retitle a post
→ update(resource: "Post", id: 42, attributes: { title: "Pyramids" })

Both tools dispatch the resource's own ActiveAdmin create or update action, so a write from MCP is the same write the admin UI makes:

  • Registered actions only — resources registered without the action (e.g. Author, registered actions :index, :show) are refused.
  • Authorization — the write runs through the resource namespace's authorization adapter for the authenticated MCP user, both before dispatch and again inside the controller, so it can only write what that user is allowed to write in admin.
  • Permitted fields only — attributes are filtered through the resource's permit_params; fields the admin form doesn't accept are silently dropped, so a slug sent to a Post permitting only title and body never reaches the record. A resource that declares no permit_params at all (like Tag) is refused outright, with a message saying so — ActiveAdmin cannot write such a resource through its own forms either. A block-form permit_params is evaluated in controller context as the authenticated MCP user, so a block varying the writable set by user gets the same answer it would give that user in admin.
  • Your callbacks run — ActiveAdmin's before_build, before_create, before_save, after_update and friends all fire, because the controller action is what fires them. A before_create that fills in a slug the form never accepted still fills it in.

A write rejected by the model comes back as a Validation failed error with the model's own messages in details, and nothing is written.

Describing a form

What can I set when writing a review?
→ describe_form(resource: "Review")          # source: "form"

What can I set when creating a post?
→ describe_form(resource: "Post")            # source: "permit_params"
→ describe_form(resource: "Post", action: "edit")

describe_form reads the resource's own form do ... end block when it declares one, reporting each input's as:, label:, hint: and — when the collection: is a literal array — its allowed values. Review declares one, so its status comes back as a select labelled "Moderation status" with ["pending", "approved"] as its allowed values. Resources that declare no form block get a description derived from their permit_params instead: ActiveAdmin renders a bare f.inputs for those, which Formtastic only expands at render time, so there is nothing to read. The response's source says which of the two you are looking at.

Either way every field is annotated from the model with the column type it is stored in and whether the model validates its presence. An associated record's fields — declared with has_many, or with inputs for: :author — are reported under nested rather than flattened in with the record's own, because a write against the parent would drop them.

A form block that declares no inputs of its own is described from permit_params too. A bare f.inputs is legal, and Formtastic only expands it against the model at render time, so there is nothing in the block to read.

action: selects the gate, not the shape — ActiveAdmin uses one form block for both. "new" (the default) requires the resource to register create and pass create authorization; "edit" requires update. Describing a form you could never submit tells you nothing you can act on, so it is refused with the same messages create and update use: Author is not creatable, and Tag declares no permitted params.

Two limits worth knowing:

  • A collection: that is an ActiveRecord::Relation or a proc is omitted rather than evaluated. Describing a form should not fire a query, and a relation can be arbitrarily large. Use an action's suggestions: when you want dynamic values.
  • A field a form block declares but permit_params omits is described and then silently dropped on write. This cannot arise on the permit_params fallback path.
  • On the permit_params fallback path, only fields backed by a database column are reported. ActiveAdmin keeps no list of the params it permits — only a method that filters against them — so the names are recovered by offering it every column the model has and seeing which survive. Permitted params that are not columns, such as tag_ids or a nested *_attributes key, are therefore missing from the description even though create and update will accept them.

Running member, collection and batch actions

ActiveAdmin actions are not exposed by default. An action becomes an MCP tool only when you add an mcp: option to it:

ActiveAdmin.register Post do
  member_action :publish, method: :post, mcp: {
    description: "Publish a post with the given visibility",
    params: {
      visibility: { type: :string, required: true,
                    enum: %w[public unlisted],
                    hint: "Who can see the post once published" }
    }
  } do
    # your existing action body, unchanged by opting in
    resource.update!(status: params[:visibility])
    redirect_to resource_path(resource), notice: "Published"
  end

  # No mcp: key, so this one is not a tool — not listed, and refused by name.
  member_action :archive, method: :post do
    resource.update!(status: "archived")
    redirect_to resource_path(resource), notice: "Archived"
  end
end

That registers a post_publish tool, and no post_archive tool. Batch actions opt in the same way, and inherit their param types from the form: hash you already declare:

batch_action :set_status, form: { status: :text },
                          mcp: { description: "Set the status on the selected posts" } do |ids, inputs|
  Post.where(id: ids).update_all(status: inputs["status"])
end

That registers a post_set_status tool taking an ids array alongside the status string it took from form:.

Declaring params

  • type: — one of :string, :integer, :number, :boolean, :array, :object.
  • required: — refuses the call when the value is missing.
  • hint: — static guidance shown to the agent. Always a plain string.
  • enum: — binding. A value outside the list is refused before dispatch.
  • suggestions: — a proc evaluated when tools are listed, e.g. suggestions: -> { Post.distinct.pluck(:status) }. Advisory only, never enforced, so use it for live values from the database. If it raises, the tool is still listed without suggestions.

On a batch action, every declared param must also appear in the action's form: hash. ActiveAdmin slices submitted inputs down to the declared form: keys before calling the block, so a param declared only under mcp: would be advertised to the client and then dropped; the declaration is refused instead.

Authorization

permission: is an additional gate, never a replacement. Every call first passes your ActiveAdmin authorization adapter exactly as query and update do; the proc can only narrow access further, never widen it. It is evaluated in controller context, so current_admin_user, can? and the usual admin helpers are available. Return false to refuse, or a String to refuse with a reason the agent can act on.

# Takes the record, so it can only be resolved when the call arrives.
member_action :feature, method: :post, mcp: {
  description: "Feature a published post on the front page",
  permission: ->(post) { post.status == "draft" ? "Only a published post can be featured" : true }
} do
  resource.update!(status: "featured")
end

# Takes no arguments, so it is resolved when tools are listed.
collection_action :purge_drafts, method: :post, mcp: {
  description: "Purge every draft post",
  permission: -> { current_admin_user&.editor? }
} do
  redirect_to collection_path, notice: "Purged"
end

Tools are also listed per user: an action whose resource the adapter refuses is left out of tools/list entirely, and a permission: proc that takes no arguments is evaluated at listing time (in the same controller context) so the tool is hidden rather than offered and then refused. A proc that takes the record cannot be resolved without one, so post_feature stays advertised and refuses at call time with the string the proc returned.

For batch actions the adapter check is necessarily resource-level — there is no single record to authorize — so it is authorized?(:<action>, YourModel) rather than a per-record policy evaluation. To stop that being a hole, the ids the client submits are run back through the adapter's scope_collection, and the whole call is refused if any of them falls outside the scope. Nothing is narrowed silently: the call either acts on every id you asked for or on none.

One caveat on authentication. Dispatch neutralises the namespace's authentication_method callback, because the MCP request has already authenticated by bearer token and that callback would otherwise redirect to a login page. If your authentication_method is a combined authentication-and- authorization method — one that also, say, rejects non-superusers — then neutralising it disables that authorization half too. Resource-level authorization still runs through the adapter, but the "we only skip authentication" framing is not universal; keep authorization in the adapter, not in the authentication callback.

What you get back

Actions are executed through your real ActiveAdmin controller, so the action's before_action chain, authorization and callbacks all run. The tool returns the response status, the redirect target and any flash messages — not the rendered HTML. An action whose body raises comes back as a generic Post#publish failed naming the resource and action, with the exception's own message — which can carry SQL, table names and file paths — kept away from the client.

Redirect-style (submit-side) actions are the supported case; a GET action that renders a full admin view is best-effort and may fail for want of a view context.

Actions declared somewhere you can't add mcp:

An action declared by a shared concern, or by another gem, has no declaration you can hang an mcp: key on — and if it did, every resource including it would get the same description, params and permission: proc. Annotate it by name from the registration instead, with mcp_action:

module Flaggable
  def self.included(dsl)
    dsl.send(:member_action, :flag, method: [:post, :delete]) { ... }
    dsl.send(:batch_action, :flag, form: proc { { reason: :text } }) { |ids, inputs| ... }
  end
end

ActiveAdmin.register Volunteer do
  include Flaggable

  mcp_action :flag, kind: :batch, tool_name: "volunteer_bulk_flag",
    description: "Flag the selected volunteers",
    params: { reason: { type: :string, required: true } }

  mcp_action :flag, kind: :member, tool_name: "volunteer_flag",
    description: "Flag a volunteer",
    params: { reason: { type: :string, required: true } }

  mcp_action :flag, kind: :member, http_verb: :delete, tool_name: "volunteer_unflag",
    description: "Remove a volunteer's flag"
end

mcp_action takes everything mcp: takes, plus:

Option Meaning
kind: :member, :collection or :batch. Optional; needed only when one name belongs to more than one action, which is refused rather than guessed.
tool_name: The MCP tool name, in place of the derived <resource>_<action>.
http_verb: Which verb to dispatch, for an action declared with several (method: [:post, :delete]). A verb the action does not answer to is a declaration error.

It annotates; it never declares. Naming an action the resource does not have warns and skips. It is resolved when the tool list is built, not when it is called, so it may appear above or below the include.

An annotation replaces an inline mcp: declaration rather than merging into it, so a shared generic declaration and a per-resource one cannot half-combine into something neither author wrote.

An action may carry more than one annotation, each producing its own tool — which is how an action answering to two verbs, one undoing the other, becomes two tools.

Opting in is still per resource. Two resources including the same concern share the actions, not the exposure: whichever does not annotate exposes nothing.

Names must not collide. A tool name carries no kind, so a member_action and a batch_action of the same name derive the same one. Rather than let one silently shadow the other, both are hidden until a tool_name: tells them apart.

Batch actions declared with a String title — the ones applications generate in loops from data — may be annotated by that title, rather than by the symbol ActiveAdmin derives from it by titleizing and underscoring, which can carry punctuation. The derived tool name has that punctuation squeezed out.

ActiveAdmin's :if proc is honoured. A batch action the admin UI hides because its :if refuses is neither listed nor runnable over MCP. ActiveAdmin itself consults :if only when rendering, so this is stricter than ActiveAdmin is — deliberately: MCP should not be the way round a gate the admin enforces by not offering the button. A proc that raises, typically because it reads request state a tool listing cannot supply, hides the tool and says so in the log.

A proc form: is evaluated in controller context, the way ActiveAdmin evaluates it, so a batch action whose form varies by resource still contributes its param types. Like suggestions:, this runs application code, and is never evaluated for a user the resource's authorization adapter refuses.

Connecting a client

activeadmin_mcp has been tested with Claude Code (Anthropic) over the HTTP transport.

claude mcp add --transport http my-app http://localhost:3000/mcp/

Or add it to your .mcp.json:

{
  "mcpServers": {
    "my-app": {
      "type": "http",
      "url": "http://localhost:3000/mcp/"
    }
  }
}

Authentication

To protect the MCP endpoint with API-token authentication, run the installer with the devise_token strategy and migrate:

rails generate activeadmin_mcp:install --auth devise_token
rails db:migrate

This will:

  • Create the mcp_api_tokens table.
  • Add an "MCP Tokens" page to your ActiveAdmin panel (app/admin/ by default).
  • Enable token authentication in the initializer.

Generator options

Option Default Description
--auth none Authentication method to use (e.g. devise_token).
--admin-path app/admin Directory for the ActiveAdmin page file.

Example with a custom admin path:

rails generate activeadmin_mcp:install --auth devise_token --admin-path app/admin/mcp

Managing tokens

  1. Log in to your ActiveAdmin panel (/admin).
  2. Navigate to MCP Tokens (or Settings > MCP Tokens if you set a menu_parent).
  3. Create a token and copy it — it is only shown once.

Connecting with a token

claude mcp add --transport http \
  --header 'Authorization: Bearer YOUR_TOKEN' \
  my-app http://localhost:3000/mcp/

Or in .mcp.json:

{
  "mcpServers": {
    "my-app": {
      "type": "http",
      "url": "http://localhost:3000/mcp/",
      "headers": {
        "Authorization": "Bearer YOUR_TOKEN"
      }
    }
  }
}

Custom auth header

If your application sits behind a reverse proxy that strips the standard Authorization header (e.g. AWS Verified Access), configure a custom header name and pass the token through it instead:

ActiveadminMcp.configure do |config|
  config.authentication_method = :devise_token
  config.auth_header_name = "X-MCP-Authorization"
end
{
  "mcpServers": {
    "my-app": {
      "type": "http",
      "url": "https://admin.example.com/admin/mcp/",
      "headers": {
        "X-MCP-Authorization": "Bearer YOUR_TOKEN"
      }
    }
  }
}

Claude Desktop (MCPB bundle)

Claude Code talks to the server over HTTP directly, but Claude Desktop cannot: its remote connector UI has no way to send an API token header. The mcpb/ directory solves this with an MCP bundle — a .mcpb file your colleagues install with a double-click.

Inside the bundle is a small Node script that speaks stdio to Claude Desktop and forwards every message, unchanged, to your server over HTTPS with the token attached. It has no dependencies and adds no capabilities of its own, so the tools it exposes are exactly the ones your server exposes.

Claude Desktop  ──stdio──▶  mcpb proxy  ──HTTPS + token──▶  your Rails app

Building the bundle

Requires Node 18 or newer. From the repository root:

cd mcpb
npm test                                   # no dependencies to install
npx @anthropic-ai/mcpb pack . ../activeadmin-mcp.mcpb

That writes activeadmin-mcp.mcpb (a zip of manifest.json, package.json and server/index.js) to the repository root. You do not bump the bundle's version by hand: publishing a GitHub Release writes the release tag into mcpb/manifest.json and mcpb/package.json, packs the bundle from that, and attaches activeadmin-mcp-<version>.mcpb to the release — so the bundle version Claude Desktop uses to detect upgrades always matches the gem version. See RELEASING.md.

So the bundle for any released version is on that release's page, and CI packs the bundle on every push and attaches it as a build artifact if you want an unreleased build from the Actions tab.

Distribute the file however suits you — pointing colleagues at the release asset, an internal file share, or an S3 bucket. Anyone with the file can install it, but it is inert without a token.

Installing

  1. Generate a token. Sign in to your admin panel, go to MCP Tokens, name the token after the machine you are installing on (e.g. "Claude Desktop — work laptop") and copy it. It is shown only once.

  2. Install the bundle. Double-click the .mcpb file, or drag it onto the Claude Desktop window, or use Settings → Extensions → Advanced settings → Install Extension….

  3. Fill in the three settings Claude Desktop prompts for:

    Setting Value
    Server URL The full MCP endpoint, e.g. https://admin.example.com/admin/mcp
    API token The token from step 1 (stored in the OS keychain, never shown again)
    Authentication header Authorization, unless your app sets a custom auth_header_name
  4. Check it works. Start a new chat and ask Claude to list the admin resources it can see. You should get back the resources your account can read.

Installation is per-person: each colleague installs the bundle and generates their own token, so every MCP call is attributed to them and constrained by their own admin permissions.

Revoking access

A token is a long-lived credential granting everything that user can do in admin. Revoke one from the same MCP Tokens page — the Last used column shows which tokens are still live. Revoking takes effect immediately; the installed bundle simply starts reporting an authentication failure.

Troubleshooting

Symptom Cause
"The server rejected the API token (HTTP 401)" The token is wrong, revoked, or being sent in the wrong header. Check the Authentication header setting matches your auth_header_name.
"Could not reach …" The URL is wrong or unreachable from this machine — check VPN, and that the URL includes the full mount path.
The extension shows no tools Claude Desktop only refreshes tools on connect. Toggle the extension off and on in Settings → Extensions.
Anything else Claude Desktop's extension logs carry the proxy's stderr output, each line prefixed [activeadmin_mcp].

Configuration

The generator writes an initializer to config/initializers/activeadmin_mcp.rb carrying every option below, commented out. Running it with --auth devise_token uncomments the authentication method for you, so the first edit is usually just naming your Devise model:

ActiveadminMcp.configure do |config|
  config.authentication_method = :devise_token
  config.user_class = "User" # your Devise model class
end
Option Default Description
authentication_method nil Set to :devise_token to enable Bearer-token auth.
user_class "User" The Devise model class name.
current_user_method :current_admin_user Controller method returning the current user.
menu_parent nil Parent menu for the MCP Tokens page (e.g. "Settings").
mount_path "/mcp" Path where the MCP server is mounted.
mount_strategy :prepend Route mounting strategy: :prepend, :append, or :none.
auth_header_name "Authorization" HTTP header to read the Bearer token from.

Route mounting

By default the engine prepends its route to the top of your application's route table. This suits most setups, but can cause problems when your admin routes use constraints (e.g. hostname-based routing), because a prepended mount sits outside any constraint blocks.

Strategy Behaviour
:prepend (default) Mounts at the top of the route table via routes.prepend.
:append Mounts at the bottom of the route table via routes.append.
:none Skips automatic mounting — you mount the engine yourself.

To mount inside a constraint block, set mount_strategy to :none and mount the engine manually:

# config/initializers/activeadmin_mcp.rb
ActiveadminMcp.configure do |config|
  config.mount_path = "/admin/mcp"
  config.mount_strategy = :none
end
# config/routes.rb (or a drawn route file)
constraints AdminConstraint.new do
  ActiveAdmin.routes(self)
  mount ActiveadminMcp::Engine => ActiveadminMcp.config.mount_path
end

Development

After checking out the repo, install dependencies and run the test suite:

bundle install
bundle exec rspec

Running the tests

bundle exec rspec (or rake spec, the default rake task) runs the unit suite only — it's fast, and it's what CI's rspec job runs. It never touches spec/e2e; that directory is excluded via .rspec.

The end-to-end suite lives in spec/e2e and runs separately:

bundle exec rake e2e

This generates a real Rails 7.2 + ActiveAdmin + Devise application under tmp/e2e_app, installs the gem into it with path:, boots it under Puma, and drives it over real HTTP with a minimal JSON-RPC client — the same way Claude Code or any other MCP client would. It's how the gem is tested against ActiveAdmin's and Ransack's actual behaviour rather than mocks.

The registrations, models, migrations and seeds that application uses are checked in under spec/e2e/fixture_app/ rather than written from heredocs, and they are the Post, Author, Review and Tag this README's examples are written against — so a claim above can be read next to both the registration it describes and the example that exercises it. spec/e2e/fixture_app/README.md says which claim each fixture exists to support.

The generated application is expensive to build (a full bundle install against rubygems.org, and a gem install rails if Rails 7.2.2.2 isn't already on your system) so it's cached in tmp/e2e_app between runs, keyed on the contents of spec/e2e/support/app_builder.rb. The first run costs a few minutes and needs network access; edit that file and the next run rebuilds from scratch, otherwise reruns are quick. Force a rebuild without editing anything by setting E2E_REBUILD=1:

E2E_REBUILD=1 bundle exec rake e2e

Contributing

Bug reports and pull requests are welcome on GitHub.

Credits

This project was forked from betacraft/active_admin_mcp, originally created by harunkumars, and has been extended from there.

License

Released under the MIT License.

About

Adding MCP support to ActiveAdmin

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages