diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 225ae02..a582846 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,18 +8,14 @@ on: jobs: rspec: runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - ruby: ["3.1", "3.2", "3.3", "3.4"] steps: - uses: actions/checkout@v4 - - name: Set up Ruby ${{ matrix.ruby }} + - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: ${{ matrix.ruby }} + ruby-version: "3.4" bundler-cache: true - name: Run specs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..121d3c8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,77 @@ +name: Release + +# Publishes the gem to RubyGems.org via Trusted Publishing (OIDC) whenever a +# GitHub Release is published. The release tag (e.g. v0.1.1) is the source of +# truth for the version: the workflow writes it into lib/activeadmin_mcp/version.rb, +# builds and publishes the gem, then commits the version bump back to the +# default branch. No API key is stored — RubyGems verifies this workflow via +# OIDC. See RELEASING.md for the one-time RubyGems setup. + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + release: + runs-on: ubuntu-latest + environment: rubygems + permissions: + contents: write # commit the version bump back to the default branch + id-token: write # exchange the OIDC token with RubyGems + + steps: + - name: Derive version from release tag + id: version + run: | + tag="${{ github.event.release.tag_name }}" + version="${tag#v}" + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.]+)?$ ]]; then + echo "::error::Release tag '$tag' is not a valid version (expected vX.Y.Z)" + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Check out default branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + + - name: Write version file + run: | + version="${{ steps.version.outputs.version }}" + sed -i -E "s/VERSION = \".*\"/VERSION = \"${version}\"/" lib/activeadmin_mcp/version.rb + grep -q "VERSION = \"${version}\"" lib/activeadmin_mcp/version.rb + + - name: Run specs + run: bundle exec rspec + + - name: Configure RubyGems credentials (OIDC) + uses: rubygems/configure-rubygems-credentials@v2.1.0 + + - name: Build and push gem + run: | + version="${{ steps.version.outputs.version }}" + gem build activeadmin_mcp.gemspec + gem push "activeadmin_mcp-${version}.gem" + + - name: Commit version bump to default branch + run: | + version="${{ steps.version.outputs.version }}" + if git diff --quiet -- lib/activeadmin_mcp/version.rb; then + echo "version.rb already at ${version}; nothing to commit." + else + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add lib/activeadmin_mcp/version.rb + git commit -m "Bump version to ${version}" + git push origin "HEAD:${{ github.event.repository.default_branch }}" + fi diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bce3a8b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - Unreleased + +Initial release. + +### Added + +- MCP server mounted as a Rails engine (default `/mcp`), speaking JSON-RPC 2.0 + over HTTP. +- `list_resources`, `query` (Ransack), and `update` tools driven by your + existing ActiveAdmin registrations. +- Optional `devise_token` Bearer-token authentication with an install + generator, an "MCP Tokens" ActiveAdmin page, and configurable auth header. +- Configurable mount strategy (`:prepend`, `:append`, `:none`) and mount path. + +[Unreleased]: https://github.com/OLIOEX/activeadmin_mcp/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/OLIOEX/activeadmin_mcp/releases/tag/v0.1.0 diff --git a/README.md b/README.md index f3fea9b..be03e3f 100644 --- a/README.md +++ b/README.md @@ -1,96 +1,152 @@ -# ActiveAdminMcp - -> **Status: Experimental / WIP** - -Minimal MCP (Model Context Protocol) server for Rails apps with ActiveAdmin. - -## Tested Clients - -- **Claude Code** (Anthropic) - HTTP transport +# ActiveadminMcp + +> **Status: Experimental / work in progress** + +`activeadmin_mcp` turns the resources you have already registered with +[ActiveAdmin](https://activeadmin.info/) into a +[Model Context Protocol](https://modelcontextprotocol.io/) (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](https://activerecord-hackery.github.io/ransack/), the same search + library ActiveAdmin uses for filtering. +- **Writes go through ActiveAdmin.** The `update` tool only writes fields + allowed by the resource's `permit_params`, refuses resources that don't + register the `update` action, and runs every change through your + authorization adapter (CanCanCan, Pundit, etc.) as the authenticated MCP + user. +- **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 >= 3.0 +- Rails >= 6.1 +- ActiveAdmin >= 2.0 ## Installation -Add to your Gemfile: +Add the gem to your Gemfile: ```ruby -gem "active_admin_mcp" +gem "activeadmin_mcp" ``` -Then run: +Install it and run the generator: ```bash bundle install -rails generate active_admin_mcp:install +rails generate activeadmin_mcp:install ``` -The MCP server is automatically mounted at `/mcp`. +The MCP server is mounted at `/mcp` automatically. That's all you need for a +read/query setup without authentication. -## Route Mounting +## Available tools -By default, the engine prepends its route to the top of your application's route table. This works well for most setups, but can cause issues when your routes use constraints (e.g. hostname-based routing for admin servers), as the prepended mount sits outside any constraint blocks. +| Tool | Description | +|------|-------------| +| `list_resources` | List every ActiveAdmin resource along with its attributes. | +| `query` | Query a resource using Ransack syntax (`limit` defaults to 25, capped at 100). | +| `update` | Update an existing record, honouring ActiveAdmin's permitted params and authorization. | -The `mount_strategy` option controls how the engine registers its route: +### Query examples -| 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 | +``` +Query users whose email contains "example.com" +→ query(resource: "User", q: { email_cont: "example.com" }) -### Manual mounting +Find active posts created since the start of the month +→ query(resource: "Post", q: { status_eq: "active", created_at_gt: "2026-08-01" }) +``` -If your admin routes are wrapped in constraints, set `mount_strategy` to `:none` and mount the engine inside your route file: +### Updating records -```ruby -# config/initializers/active_admin_mcp.rb -ActiveAdminMcp.configure do |config| - config.mount_path = "/admin/mcp" - config.mount_strategy = :none -end +``` +Update a user's name +→ update(resource: "User", id: 42, attributes: { name: "New name" }) ``` -```ruby -# config/routes.rb (or a drawn route file) -constraints AdminConstraint.new do - ActiveAdmin.routes(self) - mount ActiveAdminMcp::Engine => ActiveAdminMcp.config.mount_path -end +The `update` tool applies the same rules as the ActiveAdmin UI: + +- **Editable resources only** — resources registered without the `update` + action (e.g. `actions :index, :show`) are refused. +- **Authorization** — the change runs through the resource namespace's + authorization adapter for the authenticated MCP user, so it can only update + what that user is allowed to update in admin. +- **Permitted fields only** — attributes are filtered through the resource's + `permit_params`; fields the admin form doesn't accept are silently dropped. + +## Connecting a client + +`activeadmin_mcp` has been tested with **Claude Code** (Anthropic) over the +HTTP transport. + +```bash +claude mcp add --transport http my-app http://localhost:3000/mcp/ +``` + +Or add it to your `.mcp.json`: + +```json +{ + "mcpServers": { + "my-app": { + "type": "http", + "url": "http://localhost:3000/mcp/" + } + } +} ``` ## Authentication -To protect your MCP endpoint with API token authentication: +To protect the MCP endpoint with API-token authentication, run the installer +with the `devise_token` strategy and migrate: ```bash -rails generate active_admin_mcp:install --auth devise_token +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 +- 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 | +| `--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: ```bash -rails generate active_admin_mcp:install --auth devise_token --admin-path app/admin/mcp +rails generate activeadmin_mcp:install --auth devise_token --admin-path app/admin/mcp ``` -### Managing Tokens +### Managing tokens -1. Log in to your ActiveAdmin panel (`/admin`) -2. Navigate to **Settings > MCP Tokens** -3. Create a new token and copy it — it will only be shown once +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 +### Connecting with a token ```bash claude mcp add --transport http \ @@ -114,40 +170,19 @@ Or in `.mcp.json`: } ``` -### Configuration +### Custom auth header -The initializer at `config/initializers/active_admin_mcp.rb`: +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: ```ruby -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 that returns 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 | - -### Custom Auth Header - -If your application sits behind a reverse proxy that strips the standard `Authorization` header (e.g. AWS Verified Access), you can configure a custom header name: - -```ruby -ActiveAdminMcp.configure do |config| +ActiveadminMcp.configure do |config| config.authentication_method = :devise_token config.auth_header_name = "X-MCP-Authorization" end ``` -Then pass the token via the custom header in `.mcp.json`: - ```json { "mcpServers": { @@ -162,65 +197,80 @@ Then pass the token via the custom header in `.mcp.json`: } ``` -## Usage with Claude Code +## Configuration -```bash -claude mcp add --transport http my-app http://localhost:3000/mcp/ +The generator writes an initializer to +`config/initializers/activeadmin_mcp.rb`: + +```ruby +ActiveadminMcp.configure do |config| + config.authentication_method = :devise_token + config.user_class = "User" # your Devise model class +end ``` -Or add to your `.mcp.json`: +| 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. | -```json -{ - "mcpServers": { - "my-app": { - "type": "http", - "url": "http://localhost:3000/mcp/" - } - } -} -``` +### Route mounting -## Available Tools +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. -| Tool | Description | -|------|-------------| -| `list_resources` | List all ActiveAdmin resources with their attributes | -| `query` | Query a resource using Ransack syntax | -| `update` | Update an existing record, respecting ActiveAdmin's form and authorization rules | +| 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. | -### Query Examples +To mount inside a constraint block, set `mount_strategy` to `:none` and mount +the engine manually: +```ruby +# config/initializers/activeadmin_mcp.rb +ActiveadminMcp.configure do |config| + config.mount_path = "/admin/mcp" + config.mount_strategy = :none +end ``` -Query users where email contains "example.com" -→ query(resource: "User", q: { email_cont: "example.com" }) -Find active posts from last week -→ query(resource: "Post", q: { status_eq: "active", created_at_gt: "2025-12-01" }) +```ruby +# config/routes.rb (or a drawn route file) +constraints AdminConstraint.new do + ActiveAdmin.routes(self) + mount ActiveadminMcp::Engine => ActiveadminMcp.config.mount_path +end ``` -### Updating Records +## Development -``` -Update a user's name -→ update(resource: "User", id: 42, attributes: { name: "New name" }) +After checking out the repo, install dependencies and run the test suite: + +```bash +bundle install +bundle exec rspec ``` -The `update` tool applies the same rules as the ActiveAdmin UI: +## Contributing -- **Editable resources only** — resources registered without the `update` action - (e.g. `actions :index, :show`) are refused. -- **Authorization** — the update is run through the resource namespace's - authorization adapter for the authenticated MCP user (CanCanCan, Pundit, etc.), - so a user can only update what they're allowed to in admin. -- **Permitted fields only** — attributes are filtered through the resource's - `permit_params`, so only fields the admin form accepts are written; anything - else is silently dropped. +Bug reports and pull requests are welcome on GitHub. -## Original Project +## Credits -We forked this project from [https://github.com/betacraft/active_admin_mcp] originally and have continued to extend from there. +This project was forked from +[betacraft/active_admin_mcp](https://github.com/betacraft/active_admin_mcp), +originally created by [harunkumars](https://github.com/harunkumars), and has +been extended from there. ## License -MIT +Released under the [MIT License](LICENSE.txt). diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..3208af2 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,69 @@ +# Releasing + +`activeadmin_mcp` is published to [RubyGems.org](https://rubygems.org) via +**Trusted Publishing** (OIDC). No API key is stored in the repo or in GitHub +secrets — RubyGems verifies the `Release` GitHub Actions workflow directly. + +Releases are driven by **GitHub Releases**. Publishing a release with a +`vX.Y.Z` tag triggers `.github/workflows/release.yml`, which: + +1. Derives the version from the release tag. +2. Writes it into `lib/activeadmin_mcp/version.rb`. +3. Runs the specs, then builds and publishes the gem to RubyGems via OIDC. +4. Commits the version bump back to the default branch. + +The release tag is the single source of truth for the version — you do not edit +`version.rb` by hand. + +## One-time setup (RubyGems side) + +Because the gem does not exist on RubyGems yet, register a **pending** trusted +publisher first — this both reserves the name and authorises the workflow: + +1. Sign in at (the account must have MFA enabled — the + gemspec sets `rubygems_mfa_required`). +2. Go to . +3. Fill in: + - **RubyGems gem name:** `activeadmin_mcp` + - **GitHub repository:** `OLIOEX/activeadmin_mcp` + - **Workflow filename:** `release.yml` + - **Environment (optional but recommended):** `rubygems` +4. Save. + +Then, in the GitHub repo settings, create an **Environment** named `rubygems` +(Settings → Environments → New environment) to match the workflow's +`environment: rubygems`. Add required reviewers there if you want a manual gate +before each publish. + +Once the gem has been published the first time, the pending publisher becomes a +regular trusted publisher automatically — no further RubyGems setup is needed. + +## Cutting a release + +1. Move the relevant `CHANGELOG.md` entries under a new version heading with the + release date (open a PR and merge to `main` if you want this on the tagged + commit). +2. On GitHub, go to **Releases → Draft a new release**. +3. Create a new tag `vX.Y.Z` (targeting `main`), give the release a title and + notes, and click **Publish release**. + +Publishing the release triggers `.github/workflows/release.yml`, which bumps +`version.rb` to match the tag, runs the specs, publishes the gem to RubyGems via +OIDC, and commits the version bump back to `main`. + +> **Branch protection:** the workflow pushes the version-bump commit to the +> default branch using the built-in `GITHUB_TOKEN`. If `main` requires pull +> requests or status checks for every push, either allow the +> `github-actions[bot]` actor to bypass protection or remove the commit-back +> step and bump `version.rb` manually before releasing. + +## Building locally (optional) + +To verify the packaged gem without publishing: + +```bash +bundle exec rake build # writes pkg/activeadmin_mcp-.gem +``` + +Do **not** run `rake release` locally — publishing happens only through the +tagged CI workflow. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..b6ae734 --- /dev/null +++ b/Rakefile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +task default: :spec diff --git a/active_admin_mcp.gemspec b/activeadmin_mcp.gemspec similarity index 61% rename from active_admin_mcp.gemspec rename to activeadmin_mcp.gemspec index 14a31bf..0b34b05 100644 --- a/active_admin_mcp.gemspec +++ b/activeadmin_mcp.gemspec @@ -1,21 +1,23 @@ # frozen_string_literal: true -require_relative "lib/active_admin_mcp/version" +require_relative "lib/activeadmin_mcp/version" Gem::Specification.new do |spec| - spec.name = "active_admin_mcp" - spec.version = ActiveAdminMcp::VERSION - spec.authors = ["harunkumars"] - spec.email = ["harun@betacraft.io"] + spec.name = "activeadmin_mcp" + spec.version = ActiveadminMcp::VERSION + spec.authors = ["harunkumars", "OLIOEX"] + spec.email = ["harun@betacraft.io", "lloyd@olioex.com"] spec.summary = "MCP server for Rails apps with ActiveAdmin" spec.description = "Expose your ActiveAdmin resources to AI assistants via the Model Context Protocol (MCP)." - spec.homepage = "https://github.com/harunkumars/active_admin_mcp" + spec.homepage = "https://github.com/OLIOEX/activeadmin_mcp" spec.license = "MIT" spec.required_ruby_version = ">= 3.0.0" spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = spec.homepage + spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md" + spec.metadata["rubygems_mfa_required"] = "true" spec.files = Dir.chdir(__dir__) do Dir["{app,config,lib}/**/*", "LICENSE.txt", "README.md"] @@ -25,6 +27,7 @@ Gem::Specification.new do |spec| spec.add_dependency "rails", ">= 6.1" spec.add_dependency "activeadmin", ">= 2.0" + spec.add_development_dependency "rake", "~> 13.0" spec.add_development_dependency "rspec", "~> 3.0" spec.add_development_dependency "sqlite3", ">= 1.4" end diff --git a/app/controllers/active_admin_mcp/mcp_controller.rb b/app/controllers/activeadmin_mcp/mcp_controller.rb similarity index 90% rename from app/controllers/active_admin_mcp/mcp_controller.rb rename to app/controllers/activeadmin_mcp/mcp_controller.rb index f7d8d71..54d7120 100644 --- a/app/controllers/active_admin_mcp/mcp_controller.rb +++ b/app/controllers/activeadmin_mcp/mcp_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ActiveAdminMcp +module ActiveadminMcp class McpController < ActionController::API before_action :authenticate_mcp_token! @@ -18,7 +18,7 @@ def call private def authenticate_mcp_token! - return unless ActiveAdminMcp.config.authentication_enabled? + return unless ActiveadminMcp.config.authentication_enabled? token = extract_bearer_token unless token @@ -40,7 +40,7 @@ def authenticate_mcp_token! end def extract_bearer_token - header = request.headers[ActiveAdminMcp.config.auth_header_name] + header = request.headers[ActiveadminMcp.config.auth_header_name] return nil unless header&.start_with?("Bearer ") header.delete_prefix("Bearer ") diff --git a/app/models/active_admin_mcp/api_token.rb b/app/models/activeadmin_mcp/api_token.rb similarity index 91% rename from app/models/active_admin_mcp/api_token.rb rename to app/models/activeadmin_mcp/api_token.rb index 9586f85..d27907a 100644 --- a/app/models/active_admin_mcp/api_token.rb +++ b/app/models/activeadmin_mcp/api_token.rb @@ -3,11 +3,11 @@ require "digest" require "securerandom" -module ActiveAdminMcp +module ActiveadminMcp class ApiToken < ActiveRecord::Base self.table_name = "mcp_api_tokens" - belongs_to :user, class_name: ActiveAdminMcp.config.user_class + belongs_to :user, class_name: ActiveadminMcp.config.user_class attr_accessor :raw_token diff --git a/config/routes.rb b/config/routes.rb index 81549b9..c23449a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -ActiveAdminMcp::Engine.routes.draw do +ActiveadminMcp::Engine.routes.draw do post "/", to: "mcp#call" end diff --git a/lib/active_admin_mcp.rb b/lib/active_admin_mcp.rb deleted file mode 100644 index 522d8a6..0000000 --- a/lib/active_admin_mcp.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -require_relative "active_admin_mcp/version" -require_relative "active_admin_mcp/configuration" -require_relative "active_admin_mcp/resource_registry" -require_relative "active_admin_mcp/record_updater" -require_relative "active_admin_mcp/request_handler" -require_relative "active_admin_mcp/engine" - -module ActiveAdminMcp - class Error < StandardError; end - - class << self - def config - @config ||= Configuration.new - end - - def configure - yield config - end - end -end diff --git a/lib/active_admin_mcp/engine.rb b/lib/active_admin_mcp/engine.rb deleted file mode 100644 index df0ddac..0000000 --- a/lib/active_admin_mcp/engine.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -module ActiveAdminMcp - class Engine < ::Rails::Engine - isolate_namespace ActiveAdminMcp - - initializer "active_admin_mcp.mount" do |app| - case ActiveAdminMcp.config.mount_strategy - when :prepend - app.routes.prepend do - mount ActiveAdminMcp::Engine => ActiveAdminMcp.config.mount_path - end - when :append - app.routes.append do - mount ActiveAdminMcp::Engine => ActiveAdminMcp.config.mount_path - end - end - end - end -end diff --git a/lib/activeadmin_mcp.rb b/lib/activeadmin_mcp.rb new file mode 100644 index 0000000..481e791 --- /dev/null +++ b/lib/activeadmin_mcp.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require_relative "activeadmin_mcp/version" +require_relative "activeadmin_mcp/configuration" +require_relative "activeadmin_mcp/resource_registry" +require_relative "activeadmin_mcp/record_updater" +require_relative "activeadmin_mcp/request_handler" +require_relative "activeadmin_mcp/engine" + +module ActiveadminMcp + class Error < StandardError; end + + class << self + def config + @config ||= Configuration.new + end + + def configure + yield config + end + end +end diff --git a/lib/active_admin_mcp/configuration.rb b/lib/activeadmin_mcp/configuration.rb similarity index 97% rename from lib/active_admin_mcp/configuration.rb rename to lib/activeadmin_mcp/configuration.rb index 17f93b3..a89be64 100644 --- a/lib/active_admin_mcp/configuration.rb +++ b/lib/activeadmin_mcp/configuration.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ActiveAdminMcp +module ActiveadminMcp class Configuration MOUNT_STRATEGIES = %i[prepend append none].freeze diff --git a/lib/activeadmin_mcp/engine.rb b/lib/activeadmin_mcp/engine.rb new file mode 100644 index 0000000..c510678 --- /dev/null +++ b/lib/activeadmin_mcp/engine.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module ActiveadminMcp + class Engine < ::Rails::Engine + isolate_namespace ActiveadminMcp + + initializer "activeadmin_mcp.mount" do |app| + case ActiveadminMcp.config.mount_strategy + when :prepend + app.routes.prepend do + mount ActiveadminMcp::Engine => ActiveadminMcp.config.mount_path + end + when :append + app.routes.append do + mount ActiveadminMcp::Engine => ActiveadminMcp.config.mount_path + end + end + end + end +end diff --git a/lib/active_admin_mcp/record_updater.rb b/lib/activeadmin_mcp/record_updater.rb similarity index 99% rename from lib/active_admin_mcp/record_updater.rb rename to lib/activeadmin_mcp/record_updater.rb index 6138a8c..c4e1dcc 100644 --- a/lib/active_admin_mcp/record_updater.rb +++ b/lib/activeadmin_mcp/record_updater.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ActiveAdminMcp +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 # user must be authorized, and only fields the admin form permits are written. diff --git a/lib/active_admin_mcp/request_handler.rb b/lib/activeadmin_mcp/request_handler.rb similarity index 97% rename from lib/active_admin_mcp/request_handler.rb rename to lib/activeadmin_mcp/request_handler.rb index 204165b..57dd224 100644 --- a/lib/active_admin_mcp/request_handler.rb +++ b/lib/activeadmin_mcp/request_handler.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ActiveAdminMcp +module ActiveadminMcp class RequestHandler PROTOCOL_VERSION = "2025-06-18" @@ -34,7 +34,7 @@ def handle(request) def initialize_result { protocolVersion: PROTOCOL_VERSION, - serverInfo: { name: "active-admin-mcp", version: ActiveAdminMcp::VERSION }, + serverInfo: { name: "activeadmin-mcp", version: ActiveadminMcp::VERSION }, capabilities: { tools: {} }, } end diff --git a/lib/active_admin_mcp/resource_registry.rb b/lib/activeadmin_mcp/resource_registry.rb similarity index 98% rename from lib/active_admin_mcp/resource_registry.rb rename to lib/activeadmin_mcp/resource_registry.rb index f27f2bb..43462a4 100644 --- a/lib/active_admin_mcp/resource_registry.rb +++ b/lib/activeadmin_mcp/resource_registry.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ActiveAdminMcp +module ActiveadminMcp module ResourceRegistry class << self def all diff --git a/lib/active_admin_mcp/version.rb b/lib/activeadmin_mcp/version.rb similarity index 71% rename from lib/active_admin_mcp/version.rb rename to lib/activeadmin_mcp/version.rb index 9b76e50..3c3883b 100644 --- a/lib/active_admin_mcp/version.rb +++ b/lib/activeadmin_mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -module ActiveAdminMcp +module ActiveadminMcp VERSION = "0.1.0" end diff --git a/lib/generators/active_admin_mcp/install/install_generator.rb b/lib/generators/activeadmin_mcp/install/install_generator.rb similarity index 89% rename from lib/generators/active_admin_mcp/install/install_generator.rb rename to lib/generators/activeadmin_mcp/install/install_generator.rb index fe1325c..9cee91f 100644 --- a/lib/generators/active_admin_mcp/install/install_generator.rb +++ b/lib/generators/activeadmin_mcp/install/install_generator.rb @@ -3,7 +3,7 @@ require "rails/generators" require "rails/generators/active_record" -module ActiveAdminMcp +module ActiveadminMcp module Generators class InstallGenerator < Rails::Generators::Base include ActiveRecord::Generators::Migration @@ -16,7 +16,7 @@ class InstallGenerator < Rails::Generators::Base desc: "Path for ActiveAdmin page file" def copy_initializer - template "initializer.rb", "config/initializers/active_admin_mcp.rb" + template "initializer.rb", "config/initializers/activeadmin_mcp.rb" end def copy_migration @@ -34,7 +34,7 @@ def copy_admin_page def set_auth_config return unless auth_method - gsub_file "config/initializers/active_admin_mcp.rb", + gsub_file "config/initializers/activeadmin_mcp.rb", "# config.authentication_method = :devise_token", "config.authentication_method = :#{auth_method}" end @@ -42,7 +42,7 @@ def set_auth_config def show_instructions say "" say "=" * 60, :green - say " ActiveAdminMcp installed!", :green + say " ActiveadminMcp installed!", :green say "=" * 60, :green say "" say "Your MCP server is available at: /mcp" @@ -67,7 +67,7 @@ def show_instructions say " claude mcp add --transport http #{app_name} http://localhost:3000/mcp/" say "" say "To add authentication later:" - say " rails generate active_admin_mcp:install --auth devise_token" + say " rails generate activeadmin_mcp:install --auth devise_token" end say "" diff --git a/lib/generators/active_admin_mcp/install/templates/initializer.rb b/lib/generators/activeadmin_mcp/install/templates/initializer.rb similarity index 89% rename from lib/generators/active_admin_mcp/install/templates/initializer.rb rename to lib/generators/activeadmin_mcp/install/templates/initializer.rb index ad1d5e3..bb0abbd 100644 --- a/lib/generators/active_admin_mcp/install/templates/initializer.rb +++ b/lib/generators/activeadmin_mcp/install/templates/initializer.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true -ActiveAdminMcp.configure do |config| +ActiveadminMcp.configure do |config| # Uncomment to enable API token authentication. # Requires running the auth migration first: - # rails generate active_admin_mcp:install --auth + # rails generate activeadmin_mcp:install --auth # # config.authentication_method = :devise_token diff --git a/lib/generators/active_admin_mcp/install/templates/mcp_api_tokens.rb b/lib/generators/activeadmin_mcp/install/templates/mcp_api_tokens.rb similarity index 86% rename from lib/generators/active_admin_mcp/install/templates/mcp_api_tokens.rb rename to lib/generators/activeadmin_mcp/install/templates/mcp_api_tokens.rb index 1a3b761..170a3ea 100644 --- a/lib/generators/active_admin_mcp/install/templates/mcp_api_tokens.rb +++ b/lib/generators/activeadmin_mcp/install/templates/mcp_api_tokens.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true ActiveAdmin.register_page "MCP API Tokens" do - menu label: "MCP Tokens", parent: ActiveAdminMcp.config.menu_parent, priority: 100 + menu label: "MCP Tokens", parent: ActiveadminMcp.config.menu_parent, priority: 100 content do - @tokens = ActiveAdminMcp::ApiToken.where(user: send(ActiveAdminMcp.config.current_user_method)).order(created_at: :desc) + @tokens = ActiveadminMcp::ApiToken.where(user: send(ActiveadminMcp.config.current_user_method)).order(created_at: :desc) if flash[:mcp_raw_token] panel "New Token Created", class: "mcp-token-created" do @@ -42,8 +42,8 @@ end page_action :create, method: :post do - token = ActiveAdminMcp::ApiToken.create!( - user: send(ActiveAdminMcp.config.current_user_method), + token = ActiveadminMcp::ApiToken.create!( + user: send(ActiveadminMcp.config.current_user_method), name: params[:mcp_token][:name].presence || "Unnamed token" ) flash[:mcp_raw_token] = token.raw_token @@ -51,7 +51,7 @@ end page_action :destroy, method: :delete do - token = ActiveAdminMcp::ApiToken.where(user: send(ActiveAdminMcp.config.current_user_method)).find(params[:token_id]) + token = ActiveadminMcp::ApiToken.where(user: send(ActiveadminMcp.config.current_user_method)).find(params[:token_id]) token.destroy! flash[:notice] = "Token revoked." redirect_to admin_mcp_api_tokens_path() diff --git a/lib/generators/active_admin_mcp/install/templates/migration.rb.erb b/lib/generators/activeadmin_mcp/install/templates/migration.rb.erb similarity index 100% rename from lib/generators/active_admin_mcp/install/templates/migration.rb.erb rename to lib/generators/activeadmin_mcp/install/templates/migration.rb.erb diff --git a/spec/active_admin_mcp/api_token_spec.rb b/spec/activeadmin_mcp/api_token_spec.rb similarity index 98% rename from spec/active_admin_mcp/api_token_spec.rb rename to spec/activeadmin_mcp/api_token_spec.rb index f239fef..6468d4e 100644 --- a/spec/active_admin_mcp/api_token_spec.rb +++ b/spec/activeadmin_mcp/api_token_spec.rb @@ -3,7 +3,7 @@ require "spec_helper" require "support/active_record" -RSpec.describe ActiveAdminMcp::ApiToken do +RSpec.describe ActiveadminMcp::ApiToken do let(:user) { User.create!(email: "admin@example.com") } after do diff --git a/spec/active_admin_mcp/configuration_spec.rb b/spec/activeadmin_mcp/configuration_spec.rb similarity index 97% rename from spec/active_admin_mcp/configuration_spec.rb rename to spec/activeadmin_mcp/configuration_spec.rb index 6d33837..53f5403 100644 --- a/spec/active_admin_mcp/configuration_spec.rb +++ b/spec/activeadmin_mcp/configuration_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -RSpec.describe ActiveAdminMcp::Configuration do +RSpec.describe ActiveadminMcp::Configuration do subject(:config) { described_class.new } describe "defaults" do diff --git a/spec/active_admin_mcp/record_updater_spec.rb b/spec/activeadmin_mcp/record_updater_spec.rb similarity index 98% rename from spec/active_admin_mcp/record_updater_spec.rb rename to spec/activeadmin_mcp/record_updater_spec.rb index ff5af8b..49dc0e0 100644 --- a/spec/active_admin_mcp/record_updater_spec.rb +++ b/spec/activeadmin_mcp/record_updater_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -RSpec.describe ActiveAdminMcp::RecordUpdater do +RSpec.describe ActiveadminMcp::RecordUpdater do # A stand-in for an ActiveAdmin controller compiled from `permit_params`. # `permitted` is the set of fields the admin form would allow. def build_controller(param_key:, permitted:) diff --git a/spec/active_admin_mcp/request_handler_spec.rb b/spec/activeadmin_mcp/request_handler_spec.rb similarity index 88% rename from spec/active_admin_mcp/request_handler_spec.rb rename to spec/activeadmin_mcp/request_handler_spec.rb index a3df239..6b10a44 100644 --- a/spec/active_admin_mcp/request_handler_spec.rb +++ b/spec/activeadmin_mcp/request_handler_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -RSpec.describe ActiveAdminMcp::RequestHandler do +RSpec.describe ActiveadminMcp::RequestHandler do subject(:handler) { described_class.new } def handle(method, params = nil, id: 1) @@ -17,7 +17,7 @@ def handle(method, params = nil, id: 1) result = handle("initialize")[:result] expect(result[:protocolVersion]).to eq(described_class::PROTOCOL_VERSION) - expect(result[:serverInfo]).to eq(name: "active-admin-mcp", version: ActiveAdminMcp::VERSION) + expect(result[:serverInfo]).to eq(name: "activeadmin-mcp", version: ActiveadminMcp::VERSION) expect(result[:capabilities]).to eq(tools: {}) end @@ -83,7 +83,7 @@ def call_tool(name, arguments = {}) describe "list_resources" do it "returns the registry's resources" do resources = [{ name: "User", table: "users", attributes: %w[id email] }] - allow(ActiveAdminMcp::ResourceRegistry).to receive(:all).and_return(resources) + allow(ActiveadminMcp::ResourceRegistry).to receive(:all).and_return(resources) expect(call_tool("list_resources")).to eq("resources" => [ { "name" => "User", "table" => "users", "attributes" => %w[id email] }, @@ -100,7 +100,7 @@ def call_tool(name, arguments = {}) allow(records).to receive(:as_json).and_return(records) allow(records).to receive(:size).and_return(records.length) allow(model).to receive(:ransack).and_return(double("search", result: relation)) - allow(ActiveAdminMcp::ResourceRegistry).to receive(:find) + allow(ActiveadminMcp::ResourceRegistry).to receive(:find) .with("User").and_return(name: "User", model: model) end @@ -126,7 +126,7 @@ def call_tool(name, arguments = {}) end it "returns an error when the resource is not found" do - allow(ActiveAdminMcp::ResourceRegistry).to receive(:find).with("Ghost").and_return(nil) + allow(ActiveadminMcp::ResourceRegistry).to receive(:find).with("Ghost").and_return(nil) expect(call_tool("query", "resource" => "Ghost")) .to eq("error" => "Resource not found: Ghost") @@ -135,14 +135,14 @@ def call_tool(name, arguments = {}) describe "update" do it "returns an error when the resource is not found" do - allow(ActiveAdminMcp::ResourceRegistry).to receive(:find).with("Ghost").and_return(nil) + allow(ActiveadminMcp::ResourceRegistry).to receive(:find).with("Ghost").and_return(nil) expect(call_tool("update", "resource" => "Ghost", "id" => 1, "attributes" => { "name" => "x" })) .to eq("error" => "Resource not found: Ghost") end it "returns an error when no id is given" do - allow(ActiveAdminMcp::ResourceRegistry).to receive(:find) + allow(ActiveadminMcp::ResourceRegistry).to receive(:find) .with("User").and_return(name: "User", model: double, config: double) expect(call_tool("update", "resource" => "User", "attributes" => { "name" => "x" })) @@ -150,7 +150,7 @@ def call_tool(name, arguments = {}) end it "returns an error when no attributes are given" do - allow(ActiveAdminMcp::ResourceRegistry).to receive(:find) + allow(ActiveadminMcp::ResourceRegistry).to receive(:find) .with("User").and_return(name: "User", model: double, config: double) expect(call_tool("update", "resource" => "User", "id" => 1)) @@ -159,9 +159,9 @@ def call_tool(name, arguments = {}) it "delegates to the record updater with the resource and current user" do resource = { name: "User", model: double, config: double } - allow(ActiveAdminMcp::ResourceRegistry).to receive(:find).with("User").and_return(resource) - updater = instance_double(ActiveAdminMcp::RecordUpdater, call: { updated: [:name] }) - allow(ActiveAdminMcp::RecordUpdater).to receive(:new).and_return(updater) + allow(ActiveadminMcp::ResourceRegistry).to receive(:find).with("User").and_return(resource) + updater = instance_double(ActiveadminMcp::RecordUpdater, call: { updated: [:name] }) + allow(ActiveadminMcp::RecordUpdater).to receive(:new).and_return(updater) handler = described_class.new(current_user: :admin) response = handler.handle( @@ -174,7 +174,7 @@ def call_tool(name, arguments = {}) ) result = JSON.parse(response[:result][:content].first[:text]) - expect(ActiveAdminMcp::RecordUpdater).to have_received(:new) + expect(ActiveadminMcp::RecordUpdater).to have_received(:new) .with(resource: resource, current_user: :admin) expect(updater).to have_received(:call).with(id: 7, attributes: { "name" => "x" }) expect(result).to eq("updated" => ["name"]) diff --git a/spec/active_admin_mcp/resource_registry_spec.rb b/spec/activeadmin_mcp/resource_registry_spec.rb similarity index 98% rename from spec/active_admin_mcp/resource_registry_spec.rb rename to spec/activeadmin_mcp/resource_registry_spec.rb index a3eae38..d6a3049 100644 --- a/spec/active_admin_mcp/resource_registry_spec.rb +++ b/spec/activeadmin_mcp/resource_registry_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -RSpec.describe ActiveAdminMcp::ResourceRegistry do +RSpec.describe ActiveadminMcp::ResourceRegistry do # Builds a stand-in ActiveAdmin resource model class. `ransackable` and # `table` toggle whether it looks queryable/backed to the registry. def build_model(name:, columns: %w[id], ransackable: true, table: true) diff --git a/spec/active_admin_mcp_spec.rb b/spec/activeadmin_mcp_spec.rb similarity index 87% rename from spec/active_admin_mcp_spec.rb rename to spec/activeadmin_mcp_spec.rb index 24cd120..7a6956b 100644 --- a/spec/active_admin_mcp_spec.rb +++ b/spec/activeadmin_mcp_spec.rb @@ -2,10 +2,10 @@ require "spec_helper" -RSpec.describe ActiveAdminMcp do +RSpec.describe ActiveadminMcp do describe ".config" do it "returns a Configuration instance" do - expect(described_class.config).to be_a(ActiveAdminMcp::Configuration) + expect(described_class.config).to be_a(ActiveadminMcp::Configuration) end it "memoizes the same instance across calls" do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 5e57bd9..c064758 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,7 +3,7 @@ require "rails" require "active_record" require "action_controller" -require "active_admin_mcp" +require "activeadmin_mcp" RSpec.configure do |config| config.expect_with :rspec do |expectations| @@ -22,6 +22,6 @@ # Reset the memoized global configuration between examples so that # config-touching specs don't leak state into one another. config.after do - ActiveAdminMcp.instance_variable_set(:@config, nil) + ActiveadminMcp.instance_variable_set(:@config, nil) end end diff --git a/spec/support/active_record.rb b/spec/support/active_record.rb index b992a74..7059d69 100644 --- a/spec/support/active_record.rb +++ b/spec/support/active_record.rb @@ -24,11 +24,11 @@ add_index :mcp_api_tokens, :token_digest, unique: true end -# The ApiToken belongs_to :user, class_name: ActiveAdminMcp.config.user_class +# The ApiToken belongs_to :user, class_name: ActiveadminMcp.config.user_class # (defaults to "User"), so a matching constant must exist. class User < ActiveRecord::Base end # The model lives under app/ and is normally loaded by Rails eager-loading; # require it explicitly for the specs. -require_relative "../../app/models/active_admin_mcp/api_token" +require_relative "../../app/models/activeadmin_mcp/api_token"