Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.4"
ruby-version: "4.0.7"
bundler-cache: true

- name: Run specs
Expand Down Expand Up @@ -45,3 +45,38 @@ jobs:
with:
name: activeadmin-mcp-mcpb
path: activeadmin-mcp.mcpb

e2e:
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- uses: actions/checkout@v4

- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "4.0.7"
bundler-cache: true

# The generated host application is expensive to build (a full
# `bundle install` against rubygems.org) and its contents are decided
# by the builder source, so that is what the key hashes. Bump the v1
# prefix by hand when a new Rails or ActiveAdmin release changes what
# the generators produce without the builder itself changing.
- name: Cache the generated host application
uses: actions/cache@v4
with:
path: tmp/e2e_app
key: e2e-app-v1-${{ runner.os }}-${{ hashFiles('spec/e2e/support/app_builder.rb') }}

- name: Run the end-to-end suite
run: bundle exec rake e2e

- name: Upload the application log on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-rails-log
path: tmp/e2e_app/log/e2e.log
if-no-files-found: ignore
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ jobs:
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.4"
ruby-version: "4.0.7"
bundler-cache: true

- name: Write version file
Expand Down
1 change: 1 addition & 0 deletions .rspec
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
--require spec_helper
--format documentation
--exclude-pattern "spec/e2e/**/*_spec.rb"
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **Breaking:** the minimum supported Ruby is now 4.0 and the minimum Rails is
7.2, and ActiveAdmin is constrained to `~> 3.5`. Applications outside those
must stay on the previous release until they upgrade. Rails 7.2 is the oldest
release this gem's end-to-end suite runs on Ruby 4; the ActiveAdmin
constraint pins the gem to the 3.5 series it is tested against, and will need
raising deliberately for ActiveAdmin 4. CI and the release workflow now run
on Ruby 4.0.7.

### Security

- Enforce ActiveAdmin authorization on reads. `list_resources` and `query`
Expand Down
81 changes: 81 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# CLAUDE.md

Guidance for AI assistants working in this repository.

## What this is

`activeadmin_mcp` is a Rails engine that exposes an application's existing
ActiveAdmin resources over the Model Context Protocol. It speaks JSON-RPC 2.0
over HTTP and is mounted at `/mcp` by default. The tools it offers —
`list_resources`, `query`, `update` — are defined in
`lib/activeadmin_mcp/request_handler.rb`.

## Testing MCP actions

**Every MCP action must be covered by an end-to-end test, not only by unit
specs.** This applies to new tools, new arguments on an existing tool, and any
change to how an existing tool authorizes, filters, or writes data.

The reason is specific to this gem: almost everything it does is a claim about
code it does not own. `permit_params` is resolved by instantiating the real
ActiveAdmin controller and reading its compiled permitted params; authorization
runs through whichever adapter the host application configured; `query` hands
its arguments to Ransack; the engine mounts itself into the host's route set.
A unit spec can only assert that we called a mock the way we expected to — it
cannot show that ActiveAdmin, Ransack, Devise and Rails actually behave that
way when wired together. Several defects in this repository's history were
invisible to the unit suite for exactly that reason.

The e2e suite lives in `spec/e2e/`. It generates a real Rails + ActiveAdmin +
Devise application into `tmp/e2e_app`, installs this gem into it through the
gem's own generator, boots it under Puma, and drives the mounted endpoint over
HTTP with a real API token.

bundle exec rake spec # unit specs, fast
bundle exec rake e2e # end-to-end suite

`rake e2e` caches the generated application, so only the first run is slow.
`E2E_REBUILD=1` forces a rebuild. See the README's "Running the tests" section.

The application it generates is not written from heredocs: the ActiveAdmin
registrations, models, migrations and seeds it uses are checked in under
`spec/e2e/fixture_app/` and copied into place, so you can read and edit the
thing the suite tests against. Any static file you need to add to that
application belongs there too, not in a heredoc inside the builder.

When you add an e2e example, make sure it can actually fail. Edit the relevant
file under `spec/e2e/fixture_app/` so the behaviour under test is wrong, watch
the example fail, then restore it — editing a fixture invalidates the build
cache, so the next run picks it up. An end-to-end suite that passes regardless
of what the code does is worse than no suite, because it is believed.

Every example starts from the seeded database: a `before` hook restores it from
a snapshot taken after migrating and seeding. So examples must not depend on
what another one left behind, and the suite runs in random order to keep that
honest. Write each one as though it runs alone, because it might.

### Write e2e descriptions out in full

Give e2e examples and their enclosing blocks descriptions verbose enough that
the `--format documentation` output reads as a specification of the MCP
interface on its own, without anyone opening the file. These descriptions are
the closest thing this project has to a written contract for how the server
behaves, and they are what someone debugging a CI failure sees first.

State the behaviour and its condition, not the mechanics:

# Too terse: names a method, not a behaviour.
it "filters"

# Better: someone reading the output learns what the server guarantees.
it "filters records with Ransack syntax passed straight through to the model"

# Too terse: gives no clue why refusing is correct.
it "refuses Author"

# Better: the reason is the point of the example.
it "refuses to update a resource registered without the update action"

Prefer a long description to a comment explaining a short one. Favour the
language of the README and the MCP tools — resources, attributes, permitted
params, authorization — over the language of the implementation.
42 changes: 38 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ The server is a Rails engine mounted inside your application (by default at
are the ones you have already configured.
- **Queries use Ransack.** The `query` tool passes its arguments straight to
[Ransack](https://activerecord-hackery.github.io/ransack/), the same search
library ActiveAdmin uses for filtering.
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
Expand All @@ -37,9 +41,9 @@ The server is a Rails engine mounted inside your application (by default at

## Requirements

- Ruby >= 3.0
- Rails >= 6.1
- ActiveAdmin >= 2.0
- Ruby >= 4.0
- Rails >= 7.2
- ActiveAdmin ~> 3.5

## Installation

Expand Down Expand Up @@ -344,6 +348,36 @@ 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:

```bash
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 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`:

```bash
E2E_REBUILD=1 bundle exec rake e2e
```

## Contributing

Bug reports and pull requests are welcome on GitHub.
Expand Down
11 changes: 9 additions & 2 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
# frozen_string_literal: true

require "bundler/gem_tasks"
require "rspec/core/rake_task"

RSpec::Core::RakeTask.new(:spec)

desc "Run the end-to-end suite against a generated ActiveAdmin application"
RSpec::Core::RakeTask.new(:e2e) do |t|
t.pattern = "spec/e2e/**/*_spec.rb"
# --options replaces the project .rspec wholesale rather than merging with
# it, so the project's --require spec_helper and --exclude-pattern never
# apply here: this suite loads only spec/e2e/e2e_helper.rb.
t.rspec_opts = "--options spec/e2e/e2e.opts"
end

task default: :spec
8 changes: 3 additions & 5 deletions activeadmin_mcp.gemspec
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

require_relative "lib/activeadmin_mcp/version"

Gem::Specification.new do |spec|
Expand All @@ -12,7 +10,7 @@ Gem::Specification.new do |spec|
spec.description = "Expose your ActiveAdmin resources to AI assistants via the Model Context Protocol (MCP)."
spec.homepage = "https://github.com/OLIOEX/activeadmin_mcp"
spec.license = "MIT"
spec.required_ruby_version = ">= 3.0.0"
spec.required_ruby_version = ">= 4.0.0"

spec.metadata["homepage_uri"] = spec.homepage
spec.metadata["source_code_uri"] = spec.homepage
Expand All @@ -24,8 +22,8 @@ Gem::Specification.new do |spec|
end
spec.require_paths = ["lib"]

spec.add_dependency "rails", ">= 6.1"
spec.add_dependency "activeadmin", ">= 2.0"
spec.add_dependency "rails", ">= 7.2"
spec.add_dependency "activeadmin", "~> 3.5"

spec.add_development_dependency "rake", "~> 13.0"
spec.add_development_dependency "rspec", "~> 3.0"
Expand Down
2 changes: 0 additions & 2 deletions app/controllers/activeadmin_mcp/mcp_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

module ActiveadminMcp
class McpController < ActionController::API
before_action :authenticate_mcp_token!
Expand Down
2 changes: 0 additions & 2 deletions app/models/activeadmin_mcp/api_token.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

require "digest"
require "securerandom"

Expand Down
2 changes: 0 additions & 2 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

ActiveadminMcp::Engine.routes.draw do
post "/", to: "mcp#call"
end
2 changes: 0 additions & 2 deletions lib/activeadmin_mcp.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

require_relative "activeadmin_mcp/version"
require_relative "activeadmin_mcp/configuration"
require_relative "activeadmin_mcp/authorization"
Expand Down
2 changes: 0 additions & 2 deletions lib/activeadmin_mcp/authorization.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

module ActiveadminMcp
# Applies a resource's ActiveAdmin authorization adapter to MCP tool calls, so
# reads, listings and writes obey the same rules as the admin UI.
Expand Down
2 changes: 0 additions & 2 deletions lib/activeadmin_mcp/configuration.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

module ActiveadminMcp
class Configuration
MOUNT_STRATEGIES = %i[prepend append none].freeze
Expand Down
2 changes: 0 additions & 2 deletions lib/activeadmin_mcp/engine.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

module ActiveadminMcp
class Engine < ::Rails::Engine
isolate_namespace ActiveadminMcp
Expand Down
2 changes: 0 additions & 2 deletions lib/activeadmin_mcp/form_field_collector.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

module ActiveadminMcp
# Records the field names declared by an ActiveAdmin `form do ... end` block.
#
Expand Down
2 changes: 0 additions & 2 deletions lib/activeadmin_mcp/record_updater.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

module ActiveadminMcp
# Updates a single ActiveAdmin-managed record, enforcing the same three gates
# the admin UI would: the resource must expose the update action, the current
Expand Down
2 changes: 0 additions & 2 deletions lib/activeadmin_mcp/request_handler.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

module ActiveadminMcp
class RequestHandler
PROTOCOL_VERSION = "2025-06-18"
Expand Down
2 changes: 0 additions & 2 deletions lib/activeadmin_mcp/resource_registry.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

module ActiveadminMcp
module ResourceRegistry
class << self
Expand Down
2 changes: 0 additions & 2 deletions lib/activeadmin_mcp/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

module ActiveadminMcp
VERSION = "0.0.4"
end
2 changes: 0 additions & 2 deletions lib/generators/activeadmin_mcp/install/install_generator.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

require "rails/generators"
require "rails/generators/active_record"

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

ActiveadminMcp.configure do |config|
# Uncomment to enable API token authentication.
# Requires running the auth migration first:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

ActiveAdmin.register_page "MCP API Tokens" do
menu label: "MCP Tokens", parent: ActiveadminMcp.config.menu_parent, priority: 100

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

class CreateMcpApiTokens < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
def change
return if table_exists?(:mcp_api_tokens)
Expand Down
2 changes: 0 additions & 2 deletions spec/activeadmin_mcp/api_token_spec.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

require "spec_helper"
require "support/active_record"

Expand Down
2 changes: 0 additions & 2 deletions spec/activeadmin_mcp/authorization_spec.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe ActiveadminMcp::Authorization do
Expand Down
2 changes: 0 additions & 2 deletions spec/activeadmin_mcp/configuration_spec.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe ActiveadminMcp::Configuration do
Expand Down
2 changes: 0 additions & 2 deletions spec/activeadmin_mcp/record_updater_spec.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe ActiveadminMcp::RecordUpdater do
Expand Down
2 changes: 0 additions & 2 deletions spec/activeadmin_mcp/request_handler_spec.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe ActiveadminMcp::RequestHandler do
Expand Down
2 changes: 0 additions & 2 deletions spec/activeadmin_mcp/resource_registry_spec.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe ActiveadminMcp::ResourceRegistry do
Expand Down
Loading
Loading