From fd1de441967843bf3e35ef65053f452afeea4881 Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Tue, 17 Mar 2026 16:16:48 +0000 Subject: [PATCH 01/14] Add design spec for API token authentication Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-03-17-api-token-auth-design.md | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-17-api-token-auth-design.md diff --git a/docs/superpowers/specs/2026-03-17-api-token-auth-design.md b/docs/superpowers/specs/2026-03-17-api-token-auth-design.md new file mode 100644 index 0000000..35da510 --- /dev/null +++ b/docs/superpowers/specs/2026-03-17-api-token-auth-design.md @@ -0,0 +1,96 @@ +# API Token Authentication for ActiveAdmin MCP + +## Overview + +Add opt-in API token authentication to the MCP endpoint. Users generate tokens via an ActiveAdmin page, and MCP clients send them as Bearer tokens in the Authorization header. Tokens are hashed (SHA256) at rest. Authentication piggybacks on the host app's existing Devise user model. + +## Data Model + +### `mcp_api_tokens` table + +| Column | Type | Notes | +|---|---|---| +| `id` | bigint | PK | +| `user_id` | bigint | FK to Devise user model, indexed, not null | +| `token_digest` | string | SHA256 hash of raw token, not null, unique index | +| `name` | string | User-provided label (e.g., "Claude Code laptop"), nullable | +| `last_used_at` | datetime | Updated on each authenticated request, nullable | +| `created_at` | datetime | Rails timestamp | +| `updated_at` | datetime | Rails timestamp | + +### `ActiveAdminMcp::ApiToken` model + +- `belongs_to :user` — user class is configurable (defaults to `"User"`) +- On create: generates `SecureRandom.hex(32)` raw token, stores `Digest::SHA256.hexdigest(raw_token)` as `token_digest` +- Raw token exposed via transient `attr_accessor :raw_token`, accessible only once after creation, never persisted +- Lookup: `find_by(token_digest: Digest::SHA256.hexdigest(provided_token))` + +## Configuration + +```ruby +ActiveAdminMcp.configure do |config| + config.authentication_enabled = true + config.user_class = "User" # default +end +``` + +- `authentication_enabled` — defaults to `false` (opt-in). When false, MCP endpoint is open as today. +- `user_class` — string name of the Devise model class. Defaults to `"User"`. Used for the `belongs_to` association on `ApiToken`. + +## Authentication Flow + +In `McpController`: + +1. `before_action :authenticate_mcp_token!` — only runs when `authentication_enabled` is `true` +2. Extracts token from `Authorization: Bearer ` header +3. Looks up `ApiToken` by `token_digest: Digest::SHA256.hexdigest(raw_token)` +4. **Found:** sets `current_mcp_user` accessor, touches `last_used_at`, proceeds +5. **Not found / missing header:** returns JSON-RPC error (`-32000`, "Unauthorized") with HTTP 401 + +Stateless — no cookies or sessions. + +## ActiveAdmin Token Management Page + +A custom ActiveAdmin page ("MCP API Tokens") registered in the host app's admin namespace: + +- **Index view:** Table of the current admin user's tokens — name, created at, last used at, revoke button +- **Create action:** Form with `name` field. On submit, generates token and displays raw token once in a flash/panel with copy warning. User warned it won't be shown again. +- **Revoke action:** Deletes token record with confirmation prompt. +- **Scoped to current user** — admins only see/manage their own tokens. + +Registration: `ActiveAdminMcp.register_admin_pages!` method the user calls in an initializer (or the install generator sets up). Registers the page in ActiveAdmin's namespace. + +## Generator & Migration + +Updated `rails generate active_admin_mcp:install` will: + +1. Copy migration to create `mcp_api_tokens` table (with unique index on `token_digest`, index on `user_id`) +2. Copy initializer template to `config/initializers/active_admin_mcp.rb` +3. Copy ActiveAdmin page template to `app/admin/mcp_api_tokens.rb` +4. Print post-install instructions + +## File Changes + +### New files + +- `app/models/active_admin_mcp/api_token.rb` — token model +- `lib/generators/active_admin_mcp/install/templates/migration.rb` — migration template +- `lib/generators/active_admin_mcp/install/templates/initializer.rb` — config initializer template +- `lib/generators/active_admin_mcp/install/templates/mcp_api_tokens.rb` — ActiveAdmin page template + +### Modified files + +- `lib/active_admin_mcp.rb` — configuration module, `register_admin_pages!` method +- `app/controllers/active_admin_mcp/mcp_controller.rb` — `before_action` auth check +- `lib/generators/active_admin_mcp/install/install_generator.rb` — copy migration, initializer, admin page + +### No new gem dependencies + +SHA256 and SecureRandom are Ruby stdlib. + +## Out of Scope + +- Cookie/session handling +- Token expiry +- Rate limiting +- Scoped permissions per token From 79edfb17f5d0a1af290569a20e919f8f2473417c Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Tue, 17 Mar 2026 16:19:00 +0000 Subject: [PATCH 02/14] Address spec review feedback for API token auth Adds timing-safe comparison note, token prefix, last_used_at throttling, missing-table error handling, clarifies ActiveAdmin page registration and ResourceRegistry isolation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-03-17-api-token-auth-design.md | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/specs/2026-03-17-api-token-auth-design.md b/docs/superpowers/specs/2026-03-17-api-token-auth-design.md index 35da510..0702b0b 100644 --- a/docs/superpowers/specs/2026-03-17-api-token-auth-design.md +++ b/docs/superpowers/specs/2026-03-17-api-token-auth-design.md @@ -14,17 +14,19 @@ Add opt-in API token authentication to the MCP endpoint. Users generate tokens v | `user_id` | bigint | FK to Devise user model, indexed, not null | | `token_digest` | string | SHA256 hash of raw token, not null, unique index | | `name` | string | User-provided label (e.g., "Claude Code laptop"), nullable | -| `last_used_at` | datetime | Updated on each authenticated request, nullable | +| `last_used_at` | datetime | Updated on each authenticated request (throttled to once per 5 minutes), nullable | | `created_at` | datetime | Rails timestamp | | `updated_at` | datetime | Rails timestamp | ### `ActiveAdminMcp::ApiToken` model - `belongs_to :user` — user class is configurable (defaults to `"User"`) -- On create: generates `SecureRandom.hex(32)` raw token, stores `Digest::SHA256.hexdigest(raw_token)` as `token_digest` +- On create: generates `SecureRandom.hex(32)` raw token with `aamcp_` prefix for identifiability, stores `Digest::SHA256.hexdigest(raw_token)` as `token_digest` - Raw token exposed via transient `attr_accessor :raw_token`, accessible only once after creation, never persisted - Lookup: `find_by(token_digest: Digest::SHA256.hexdigest(provided_token))` +**Security note:** Token lookup uses a database `WHERE` equality on the SHA256 digest. This is timing-safe because the comparison is on an irreversible hash — an attacker cannot exploit timing differences to recover the raw token from its digest. No additional constant-time comparison is needed for the DB lookup path. If any future code adds in-Ruby comparison of raw tokens, it must use `ActiveSupport::SecurityUtils.secure_compare`. + ## Configuration ```ruby @@ -44,22 +46,23 @@ In `McpController`: 1. `before_action :authenticate_mcp_token!` — only runs when `authentication_enabled` is `true` 2. Extracts token from `Authorization: Bearer ` header 3. Looks up `ApiToken` by `token_digest: Digest::SHA256.hexdigest(raw_token)` -4. **Found:** sets `current_mcp_user` accessor, touches `last_used_at`, proceeds +4. **Found:** sets `current_mcp_user` accessor, touches `last_used_at` (throttled — only if nil or older than 5 minutes), proceeds 5. **Not found / missing header:** returns JSON-RPC error (`-32000`, "Unauthorized") with HTTP 401 +6. **Table missing** (`ActiveRecord::StatementInvalid`): rescued in `before_action`, returns JSON-RPC error (`-32000`, "Authentication not configured — run migrations") with HTTP 500 -Stateless — no cookies or sessions. +Stateless — no cookies or sessions. Works for both POST requests and future GET-based SSE transport, since `before_action` applies to all controller actions. ## ActiveAdmin Token Management Page -A custom ActiveAdmin page ("MCP API Tokens") registered in the host app's admin namespace: +A template file copied to `app/admin/mcp_api_tokens.rb` during installation. ActiveAdmin auto-loads files in `app/admin/`, so no programmatic registration method is needed. + +This is an ActiveAdmin **Page** (not a Resource), so it will not be discovered by `ResourceRegistry` and `token_digest` will not be exposed through MCP tools. -- **Index view:** Table of the current admin user's tokens — name, created at, last used at, revoke button -- **Create action:** Form with `name` field. On submit, generates token and displays raw token once in a flash/panel with copy warning. User warned it won't be shown again. +- **Index view:** Table of the current user's tokens — name, created at, last used at, revoke button. Uses `current_admin_user` (Devise's standard helper available in ActiveAdmin controllers) to scope queries. +- **Create action:** Form with `name` field. On submit, generates token and displays raw token once in a flash/panel with copy warning. User warned it won't be shown again. Uses standard ActiveAdmin form submissions (CSRF handled automatically by ActionController::Base). - **Revoke action:** Deletes token record with confirmation prompt. - **Scoped to current user** — admins only see/manage their own tokens. -Registration: `ActiveAdminMcp.register_admin_pages!` method the user calls in an initializer (or the install generator sets up). Registers the page in ActiveAdmin's namespace. - ## Generator & Migration Updated `rails generate active_admin_mcp:install` will: @@ -69,6 +72,8 @@ Updated `rails generate active_admin_mcp:install` will: 3. Copy ActiveAdmin page template to `app/admin/mcp_api_tokens.rb` 4. Print post-install instructions +The generator declares `source_root File.expand_path("templates", __dir__)` to resolve template paths. + ## File Changes ### New files @@ -80,9 +85,9 @@ Updated `rails generate active_admin_mcp:install` will: ### Modified files -- `lib/active_admin_mcp.rb` — configuration module, `register_admin_pages!` method -- `app/controllers/active_admin_mcp/mcp_controller.rb` — `before_action` auth check -- `lib/generators/active_admin_mcp/install/install_generator.rb` — copy migration, initializer, admin page +- `lib/active_admin_mcp.rb` — configuration module +- `app/controllers/active_admin_mcp/mcp_controller.rb` — `before_action` auth check with missing-table rescue +- `lib/generators/active_admin_mcp/install/install_generator.rb` — add `source_root`, copy migration, initializer, admin page ### No new gem dependencies From ddcc085c068b6152dfb0de93b78da24093550ead Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Tue, 17 Mar 2026 16:25:48 +0000 Subject: [PATCH 03/14] Add implementation plan for API token authentication Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-17-api-token-auth.md | 639 ++++++++++++++++++ 1 file changed, 639 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-17-api-token-auth.md diff --git a/docs/superpowers/plans/2026-03-17-api-token-auth.md b/docs/superpowers/plans/2026-03-17-api-token-auth.md new file mode 100644 index 0000000..c6f9557 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-api-token-auth.md @@ -0,0 +1,639 @@ +# API Token Authentication Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add opt-in Bearer token authentication to the MCP endpoint, backed by Devise user model with hashed tokens and an ActiveAdmin management page. + +**Architecture:** Configuration module on `ActiveAdminMcp` with `authentication_method` and `user_class` settings. `ApiToken` model stores SHA256-hashed tokens linked to users. `McpController` gains a conditional `before_action` that validates Bearer tokens. Install generator gets `--auth` flag to conditionally scaffold migration + admin page. + +**Tech Stack:** Ruby/Rails engine, ActiveAdmin, Devise (host app), SHA256 (stdlib), SecureRandom (stdlib) + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|---------------| +| `lib/active_admin_mcp.rb` | Modify | Configuration module (`configure`, `config`) | +| `lib/active_admin_mcp/configuration.rb` | Create | Configuration class with `authentication_method` and `user_class` | +| `app/models/active_admin_mcp/api_token.rb` | Create | Token model — generation, hashing, lookup | +| `app/controllers/active_admin_mcp/mcp_controller.rb` | Modify | `before_action` auth check | +| `lib/generators/active_admin_mcp/install/install_generator.rb` | Modify | `--auth` flag, template copying | +| `lib/generators/active_admin_mcp/install/templates/initializer.rb` | Create | Initializer template | +| `lib/generators/active_admin_mcp/install/templates/migration.rb.erb` | Create | Migration template | +| `lib/generators/active_admin_mcp/install/templates/mcp_api_tokens.rb` | Create | ActiveAdmin page template | +| `README.md` | Modify | Auth documentation | + +--- + +### Task 1: Configuration Module + +**Files:** +- Create: `lib/active_admin_mcp/configuration.rb` +- Modify: `lib/active_admin_mcp.rb` + +- [ ] **Step 1: Create the Configuration class** + +Create `lib/active_admin_mcp/configuration.rb`: + +```ruby +# frozen_string_literal: true + +module ActiveAdminMcp + class Configuration + attr_accessor :authentication_method, :user_class + + def initialize + @authentication_method = nil + @user_class = "User" + end + + def authentication_enabled? + authentication_method == :devise_token + end + end +end +``` + +- [ ] **Step 2: Add configure/config to main module** + +Modify `lib/active_admin_mcp.rb` to add: + +```ruby +# 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/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 +``` + +- [ ] **Step 3: Commit** + +```bash +git add lib/active_admin_mcp/configuration.rb lib/active_admin_mcp.rb +git commit -m "feat: add configuration module with authentication_method and user_class" +``` + +--- + +### Task 2: ApiToken Model + +**Files:** +- Create: `app/models/active_admin_mcp/api_token.rb` + +- [ ] **Step 1: Create the ApiToken model** + +Create `app/models/active_admin_mcp/api_token.rb`: + +```ruby +# frozen_string_literal: true + +require "digest" +require "securerandom" + +module ActiveAdminMcp + class ApiToken < ActiveRecord::Base + self.table_name = "mcp_api_tokens" + + belongs_to :user, class_name: ActiveAdminMcp.config.user_class + + attr_accessor :raw_token + + validates :token_digest, presence: true, uniqueness: true + validates :user_id, presence: true + + before_validation :generate_token, on: :create + + LAST_USED_THROTTLE = 5.minutes + + def self.find_by_raw_token(raw_token) + return nil if raw_token.blank? + + find_by(token_digest: digest(raw_token)) + end + + def self.digest(raw_token) + Digest::SHA256.hexdigest(raw_token) + end + + def touch_last_used! + return if last_used_at.present? && last_used_at > LAST_USED_THROTTLE.ago + + update_column(:last_used_at, Time.current) + end + + private + + def generate_token + self.raw_token = "aamcp_#{SecureRandom.hex(32)}" + self.token_digest = self.class.digest(raw_token) + end + end +end +``` + +- [ ] **Step 2: Commit** + +```bash +git add app/models/active_admin_mcp/api_token.rb +git commit -m "feat: add ApiToken model with SHA256 hashing and throttled last_used_at" +``` + +--- + +### Task 3: Controller Authentication + +**Files:** +- Modify: `app/controllers/active_admin_mcp/mcp_controller.rb` + +- [ ] **Step 1: Add before_action auth to McpController** + +Replace the full content of `app/controllers/active_admin_mcp/mcp_controller.rb`: + +```ruby +# frozen_string_literal: true + +module ActiveAdminMcp + class McpController < ActionController::API + before_action :authenticate_mcp_token! + + attr_reader :current_mcp_user + + def call + request_body = JSON.parse(request.body.read) + response = RequestHandler.new.handle(request_body) + + response ? render(json: response) : head(:no_content) + rescue JSON::ParserError => e + render json: { jsonrpc: "2.0", error: { code: -32_700, message: e.message } }, status: :bad_request + end + + private + + def authenticate_mcp_token! + return unless ActiveAdminMcp.config.authentication_enabled? + + token = extract_bearer_token + unless token + render json: jsonrpc_error(-32_000, "Unauthorized"), status: :unauthorized + return + end + + api_token = ApiToken.find_by_raw_token(token) + unless api_token + render json: jsonrpc_error(-32_000, "Unauthorized"), status: :unauthorized + return + end + + @current_mcp_user = api_token.user + api_token.touch_last_used! + rescue ActiveRecord::StatementInvalid + render json: jsonrpc_error(-32_000, "Authentication not configured — run migrations"), + status: :internal_server_error + end + + def extract_bearer_token + header = request.headers["Authorization"] + return nil unless header&.start_with?("Bearer ") + + header.delete_prefix("Bearer ") + end + + def jsonrpc_error(code, message) + { jsonrpc: "2.0", id: nil, error: { code: code, message: message } } + end + end +end +``` + +- [ ] **Step 2: Commit** + +```bash +git add app/controllers/active_admin_mcp/mcp_controller.rb +git commit -m "feat: add Bearer token authentication to MCP controller" +``` + +--- + +### Task 4: Generator Templates + +**Files:** +- Create: `lib/generators/active_admin_mcp/install/templates/initializer.rb` +- Create: `lib/generators/active_admin_mcp/install/templates/migration.rb.erb` +- Create: `lib/generators/active_admin_mcp/install/templates/mcp_api_tokens.rb` + +- [ ] **Step 1: Create initializer template** + +Create `lib/generators/active_admin_mcp/install/templates/initializer.rb`: + +```ruby +# frozen_string_literal: true + +ActiveAdminMcp.configure do |config| + # Uncomment to enable API token authentication. + # Requires running the auth migration first: + # rails generate active_admin_mcp:install --auth + # + # config.authentication_method = :devise_token + + # The Devise model class used for authentication. + # config.user_class = "User" +end +``` + +- [ ] **Step 2: Create migration template** + +Create `lib/generators/active_admin_mcp/install/templates/migration.rb.erb`: + +```erb +# frozen_string_literal: true + +class CreateMcpApiTokens < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] + def change + create_table :mcp_api_tokens do |t| + t.references :user, null: false, foreign_key: true + t.string :token_digest, null: false + t.string :name + t.datetime :last_used_at + + t.timestamps + end + + add_index :mcp_api_tokens, :token_digest, unique: true + end +end +``` + +- [ ] **Step 3: Create ActiveAdmin page template** + +Create `lib/generators/active_admin_mcp/install/templates/mcp_api_tokens.rb`: + +```ruby +# frozen_string_literal: true + +ActiveAdmin.register_page "MCP API Tokens" do + menu label: "MCP Tokens", parent: "Settings", priority: 100 + + content do + @tokens = ActiveAdminMcp::ApiToken.where(user: current_admin_user).order(created_at: :desc) + + if flash[:mcp_raw_token] + panel "New Token Created", class: "mcp-token-created" do + para "Copy this token now — it will not be shown again:" + pre flash[:mcp_raw_token], class: "mcp-raw-token" + end + end + + panel "Your MCP API Tokens" do + table_for @tokens do + column :name + column(:created_at) { |t| l(t.created_at, format: :long) } + column(:last_used_at) { |t| t.last_used_at ? l(t.last_used_at, format: :long) : "Never" } + column "Actions" do |token| + link_to "Revoke", admin_mcp_api_tokens_path(token_id: token.id), + method: :delete, + data: { confirm: "Revoke token '#{token.name}'?" }, + class: "button small" + end + end + + if @tokens.empty? + para "No tokens yet. Create one to authenticate MCP clients." + end + end + + panel "Create New Token" do + active_admin_form_for :mcp_token, url: admin_mcp_api_tokens_path, method: :post do |f| + f.inputs do + f.input :name, as: :string, label: "Token Name", hint: "A label to identify this token (e.g., 'Claude Code laptop')" + end + f.actions do + f.action :submit, label: "Generate Token" + end + end + end + end + + page_action :create, method: :post do + token = ActiveAdminMcp::ApiToken.create!( + user: current_admin_user, + name: params[:mcp_token][:name].presence || "Unnamed token" + ) + flash[:mcp_raw_token] = token.raw_token + redirect_to admin_mcp_api_tokens_path + end + + page_action :destroy, method: :delete do + token = ActiveAdminMcp::ApiToken.where(user: current_admin_user).find(params[:token_id]) + token.destroy! + flash[:notice] = "Token revoked." + redirect_to admin_mcp_api_tokens_path + end +end +``` + +- [ ] **Step 4: Commit** + +```bash +git add lib/generators/active_admin_mcp/install/templates/ +git commit -m "feat: add generator templates for initializer, migration, and admin page" +``` + +--- + +### Task 5: Update Install Generator + +**Files:** +- Modify: `lib/generators/active_admin_mcp/install/install_generator.rb` + +- [ ] **Step 1: Rewrite the install generator with --auth flag** + +Replace the full content of `lib/generators/active_admin_mcp/install/install_generator.rb`: + +```ruby +# frozen_string_literal: true + +require "rails/generators" +require "rails/generators/active_record" + +module ActiveAdminMcp + module Generators + class InstallGenerator < Rails::Generators::Base + include ActiveRecord::Generators::Migration + + source_root File.expand_path("templates", __dir__) + + class_option :auth, type: :boolean, default: false, + desc: "Generate authentication support (migration, admin page, config)" + + def copy_initializer + template "initializer.rb", "config/initializers/active_admin_mcp.rb" + end + + def copy_migration + return unless options[:auth] + + migration_template "migration.rb.erb", "db/migrate/create_mcp_api_tokens.rb" + end + + def copy_admin_page + return unless options[:auth] + + copy_file "mcp_api_tokens.rb", "app/admin/mcp_api_tokens.rb" + end + + def uncomment_auth_config + return unless options[:auth] + + gsub_file "config/initializers/active_admin_mcp.rb", + "# config.authentication_method = :devise_token", + "config.authentication_method = :devise_token" + end + + def show_instructions + say "" + say "=" * 60, :green + say " ActiveAdminMcp installed!", :green + say "=" * 60, :green + say "" + say "Your MCP server is available at: /mcp" + say "" + + if options[:auth] + say "Authentication enabled! Next steps:", :yellow + say "" + say " 1. Run migrations:" + say " rails db:migrate" + say "" + say " 2. Create tokens via ActiveAdmin:" + say " Log in to /admin and visit 'MCP Tokens' under Settings" + say "" + say " 3. Connect Claude Code with your token:" + say " claude mcp add --transport http \\", :cyan + say " --header 'Authorization: Bearer YOUR_TOKEN' \\", :cyan + say " #{app_name} http://localhost:3000/mcp/", :cyan + else + say "Connect Claude Code:" + say "" + 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" + end + + say "" + end + + private + + def app_name + Rails.application.class.module_parent_name.underscore.dasherize + rescue StandardError + "my-app" + end + end + end +end +``` + +- [ ] **Step 2: Commit** + +```bash +git add lib/generators/active_admin_mcp/install/install_generator.rb +git commit -m "feat: update install generator with --auth flag for conditional auth setup" +``` + +--- + +### Task 6: Update README + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Update README with authentication docs** + +Replace the full `README.md` with: + +```markdown +# ActiveAdminMcp + +> **Status: Experimental / WIP** + +Minimal MCP (Model Context Protocol) server for Rails apps with ActiveAdmin. + +## Tested Clients + +- **Claude Code** (Anthropic) - HTTP transport + +## Installation + +Add to your Gemfile: + +```ruby +gem "active_admin_mcp" +``` + +Then run: + +```bash +bundle install +rails generate active_admin_mcp:install +``` + +The MCP server is automatically mounted at `/mcp`. + +## Authentication + +To protect your MCP endpoint with API token authentication: + +```bash +rails generate active_admin_mcp:install --auth +rails db:migrate +``` + +This will: +- Create the `mcp_api_tokens` table +- Add an "MCP Tokens" page to your ActiveAdmin panel +- Enable token authentication in the initializer + +### 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 + +### Connecting with a Token + +```bash +claude mcp add --transport http \ + --header 'Authorization: Bearer YOUR_TOKEN' \ + my-app http://localhost:3000/mcp/ +``` + +Or in `.mcp.json`: + +```json +{ + "mcpServers": { + "my-app": { + "type": "http", + "url": "http://localhost:3000/mcp/", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + } + } + } +} +``` + +### Configuration + +The initializer at `config/initializers/active_admin_mcp.rb`: + +```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 | + +## Usage with Claude Code + +```bash +claude mcp add --transport http my-app http://localhost:3000/mcp/ +``` + +Or add to your `.mcp.json`: + +```json +{ + "mcpServers": { + "my-app": { + "type": "http", + "url": "http://localhost:3000/mcp/" + } + } +} +``` + +## Available Tools + +| Tool | Description | +|------|-------------| +| `list_resources` | List all ActiveAdmin resources with their attributes | +| `query` | Query a resource using Ransack syntax | + +### Query Examples + +``` +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" }) +``` + +## Future Ideas + +- [ ] SSE transport support for streaming +- [ ] Configurable resource allowlist +- [ ] Write operations (create, update, delete) +- [ ] Custom tool definitions per resource +- [ ] Rate limiting + +## License + +MIT +``` + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: add authentication setup guide to README" +``` + +--- + +### Task 7: Final Verification + +- [ ] **Step 1: Verify all files exist** + +Run: `find /Users/lloyd/Code/active_admin_mcp -name "*.rb" -path "*/active_admin_mcp/*" | sort` + +Expected new files present: +- `app/models/active_admin_mcp/api_token.rb` +- `lib/active_admin_mcp/configuration.rb` +- `lib/generators/active_admin_mcp/install/templates/initializer.rb` +- `lib/generators/active_admin_mcp/install/templates/mcp_api_tokens.rb` + +And: `find /Users/lloyd/Code/active_admin_mcp -name "*.erb" | sort` + +Expected: `lib/generators/active_admin_mcp/install/templates/migration.rb.erb` + +- [ ] **Step 2: Verify Ruby syntax on all files** + +Run: `for f in app/models/active_admin_mcp/api_token.rb lib/active_admin_mcp/configuration.rb lib/active_admin_mcp.rb app/controllers/active_admin_mcp/mcp_controller.rb lib/generators/active_admin_mcp/install/install_generator.rb lib/generators/active_admin_mcp/install/templates/initializer.rb lib/generators/active_admin_mcp/install/templates/mcp_api_tokens.rb; do ruby -c "$f"; done` + +Expected: `Syntax OK` for each file From e8b49fdb50859f40ecb79d21fdf6d9839d1886d3 Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Tue, 17 Mar 2026 16:30:48 +0000 Subject: [PATCH 04/14] feat: add configuration module with authentication_method and user_class --- lib/active_admin_mcp.rb | 11 +++++++++++ lib/active_admin_mcp/configuration.rb | 16 ++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 lib/active_admin_mcp/configuration.rb diff --git a/lib/active_admin_mcp.rb b/lib/active_admin_mcp.rb index 909d5cc..590d735 100644 --- a/lib/active_admin_mcp.rb +++ b/lib/active_admin_mcp.rb @@ -1,10 +1,21 @@ # 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/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/configuration.rb b/lib/active_admin_mcp/configuration.rb new file mode 100644 index 0000000..c7f3540 --- /dev/null +++ b/lib/active_admin_mcp/configuration.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module ActiveAdminMcp + class Configuration + attr_accessor :authentication_method, :user_class + + def initialize + @authentication_method = nil + @user_class = "User" + end + + def authentication_enabled? + authentication_method == :devise_token + end + end +end From a2c9092511d6adc655be48882ac9cfc5900c4d9c Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Tue, 17 Mar 2026 16:31:20 +0000 Subject: [PATCH 05/14] feat: add ApiToken model with SHA256 hashing and throttled last_used_at --- app/models/active_admin_mcp/api_token.rb | 44 ++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 app/models/active_admin_mcp/api_token.rb diff --git a/app/models/active_admin_mcp/api_token.rb b/app/models/active_admin_mcp/api_token.rb new file mode 100644 index 0000000..9586f85 --- /dev/null +++ b/app/models/active_admin_mcp/api_token.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "digest" +require "securerandom" + +module ActiveAdminMcp + class ApiToken < ActiveRecord::Base + self.table_name = "mcp_api_tokens" + + belongs_to :user, class_name: ActiveAdminMcp.config.user_class + + attr_accessor :raw_token + + validates :token_digest, presence: true, uniqueness: true + validates :user_id, presence: true + + before_validation :generate_token, on: :create + + LAST_USED_THROTTLE = 5.minutes + + def self.find_by_raw_token(raw_token) + return nil if raw_token.blank? + + find_by(token_digest: digest(raw_token)) + end + + def self.digest(raw_token) + Digest::SHA256.hexdigest(raw_token) + end + + def touch_last_used! + return if last_used_at.present? && last_used_at > LAST_USED_THROTTLE.ago + + update_column(:last_used_at, Time.current) + end + + private + + def generate_token + self.raw_token = "aamcp_#{SecureRandom.hex(32)}" + self.token_digest = self.class.digest(raw_token) + end + end +end From c67ef8acb5dff44a681be5e4fa5a3b869b197ca4 Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Tue, 17 Mar 2026 16:31:52 +0000 Subject: [PATCH 06/14] feat: add Bearer token authentication to MCP controller --- .../active_admin_mcp/mcp_controller.rb | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/app/controllers/active_admin_mcp/mcp_controller.rb b/app/controllers/active_admin_mcp/mcp_controller.rb index c4ede10..a36ed6a 100644 --- a/app/controllers/active_admin_mcp/mcp_controller.rb +++ b/app/controllers/active_admin_mcp/mcp_controller.rb @@ -2,6 +2,10 @@ module ActiveAdminMcp class McpController < ActionController::API + before_action :authenticate_mcp_token! + + attr_reader :current_mcp_user + def call request_body = JSON.parse(request.body.read) response = RequestHandler.new.handle(request_body) @@ -10,5 +14,40 @@ def call rescue JSON::ParserError => e render json: { jsonrpc: "2.0", error: { code: -32_700, message: e.message } }, status: :bad_request end + + private + + def authenticate_mcp_token! + return unless ActiveAdminMcp.config.authentication_enabled? + + token = extract_bearer_token + unless token + render json: jsonrpc_error(-32_000, "Unauthorized"), status: :unauthorized + return + end + + api_token = ApiToken.find_by_raw_token(token) + unless api_token + render json: jsonrpc_error(-32_000, "Unauthorized"), status: :unauthorized + return + end + + @current_mcp_user = api_token.user + api_token.touch_last_used! + rescue ActiveRecord::StatementInvalid + render json: jsonrpc_error(-32_000, "Authentication not configured — run migrations"), + status: :internal_server_error + end + + def extract_bearer_token + header = request.headers["Authorization"] + return nil unless header&.start_with?("Bearer ") + + header.delete_prefix("Bearer ") + end + + def jsonrpc_error(code, message) + { jsonrpc: "2.0", id: nil, error: { code: code, message: message } } + end end end From 4ef3525d3b889e8404d868693f37f979d408ecf9 Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Tue, 17 Mar 2026 16:32:40 +0000 Subject: [PATCH 07/14] feat: add generator templates for initializer, migration, and admin page --- .../install/templates/initializer.rb | 12 ++++ .../install/templates/mcp_api_tokens.rb | 61 +++++++++++++++++++ .../install/templates/migration.rb.erb | 16 +++++ 3 files changed, 89 insertions(+) create mode 100644 lib/generators/active_admin_mcp/install/templates/initializer.rb create mode 100644 lib/generators/active_admin_mcp/install/templates/mcp_api_tokens.rb create mode 100644 lib/generators/active_admin_mcp/install/templates/migration.rb.erb diff --git a/lib/generators/active_admin_mcp/install/templates/initializer.rb b/lib/generators/active_admin_mcp/install/templates/initializer.rb new file mode 100644 index 0000000..38b487f --- /dev/null +++ b/lib/generators/active_admin_mcp/install/templates/initializer.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +ActiveAdminMcp.configure do |config| + # Uncomment to enable API token authentication. + # Requires running the auth migration first: + # rails generate active_admin_mcp:install --auth + # + # config.authentication_method = :devise_token + + # The Devise model class used for authentication. + # config.user_class = "User" +end diff --git a/lib/generators/active_admin_mcp/install/templates/mcp_api_tokens.rb b/lib/generators/active_admin_mcp/install/templates/mcp_api_tokens.rb new file mode 100644 index 0000000..18cf969 --- /dev/null +++ b/lib/generators/active_admin_mcp/install/templates/mcp_api_tokens.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +ActiveAdmin.register_page "MCP API Tokens" do + menu label: "MCP Tokens", parent: "Settings", priority: 100 + + content do + @tokens = ActiveAdminMcp::ApiToken.where(user: current_admin_user).order(created_at: :desc) + + if flash[:mcp_raw_token] + panel "New Token Created", class: "mcp-token-created" do + para "Copy this token now — it will not be shown again:" + pre flash[:mcp_raw_token], class: "mcp-raw-token" + end + end + + panel "Your MCP API Tokens" do + table_for @tokens do + column :name + column(:created_at) { |t| l(t.created_at, format: :long) } + column(:last_used_at) { |t| t.last_used_at ? l(t.last_used_at, format: :long) : "Never" } + column "Actions" do |token| + link_to "Revoke", admin_mcp_api_tokens_path(token_id: token.id), + method: :delete, + data: { confirm: "Revoke token '#{token.name}'?" }, + class: "button small" + end + end + + if @tokens.empty? + para "No tokens yet. Create one to authenticate MCP clients." + end + end + + panel "Create New Token" do + active_admin_form_for :mcp_token, url: admin_mcp_api_tokens_path, method: :post do |f| + f.inputs do + f.input :name, as: :string, label: "Token Name", hint: "A label to identify this token (e.g., 'Claude Code laptop')" + end + f.actions do + f.action :submit, label: "Generate Token" + end + end + end + end + + page_action :create, method: :post do + token = ActiveAdminMcp::ApiToken.create!( + user: current_admin_user, + name: params[:mcp_token][:name].presence || "Unnamed token" + ) + flash[:mcp_raw_token] = token.raw_token + redirect_to admin_mcp_api_tokens_path + end + + page_action :destroy, method: :delete do + token = ActiveAdminMcp::ApiToken.where(user: current_admin_user).find(params[:token_id]) + token.destroy! + flash[:notice] = "Token revoked." + redirect_to admin_mcp_api_tokens_path + end +end diff --git a/lib/generators/active_admin_mcp/install/templates/migration.rb.erb b/lib/generators/active_admin_mcp/install/templates/migration.rb.erb new file mode 100644 index 0000000..6c3fd3c --- /dev/null +++ b/lib/generators/active_admin_mcp/install/templates/migration.rb.erb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateMcpApiTokens < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] + def change + create_table :mcp_api_tokens do |t| + t.references :user, null: false, foreign_key: true + t.string :token_digest, null: false + t.string :name + t.datetime :last_used_at + + t.timestamps + end + + add_index :mcp_api_tokens, :token_digest, unique: true + end +end From be809c7d76e139de039641f1a500d60ecc3f0ebf Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Tue, 17 Mar 2026 16:33:15 +0000 Subject: [PATCH 08/14] feat: update install generator with --auth flag for conditional auth setup --- .../install/install_generator.rb | 60 +++++++++++++++++-- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/lib/generators/active_admin_mcp/install/install_generator.rb b/lib/generators/active_admin_mcp/install/install_generator.rb index 84d8bd9..326e307 100644 --- a/lib/generators/active_admin_mcp/install/install_generator.rb +++ b/lib/generators/active_admin_mcp/install/install_generator.rb @@ -1,9 +1,41 @@ # frozen_string_literal: true +require "rails/generators" +require "rails/generators/active_record" + module ActiveAdminMcp module Generators class InstallGenerator < Rails::Generators::Base - desc "Shows MCP setup instructions" + include ActiveRecord::Generators::Migration + + source_root File.expand_path("templates", __dir__) + + class_option :auth, type: :boolean, default: false, + desc: "Generate authentication support (migration, admin page, config)" + + def copy_initializer + template "initializer.rb", "config/initializers/active_admin_mcp.rb" + end + + def copy_migration + return unless options[:auth] + + migration_template "migration.rb.erb", "db/migrate/create_mcp_api_tokens.rb" + end + + def copy_admin_page + return unless options[:auth] + + copy_file "mcp_api_tokens.rb", "app/admin/mcp_api_tokens.rb" + end + + def uncomment_auth_config + return unless options[:auth] + + gsub_file "config/initializers/active_admin_mcp.rb", + "# config.authentication_method = :devise_token", + "config.authentication_method = :devise_token" + end def show_instructions say "" @@ -13,9 +45,29 @@ def show_instructions say "" say "Your MCP server is available at: /mcp" say "" - say "Connect Claude Code:" - say "" - say " claude mcp add --transport http #{app_name} http://localhost:3000/mcp/" + + if options[:auth] + say "Authentication enabled! Next steps:", :yellow + say "" + say " 1. Run migrations:" + say " rails db:migrate" + say "" + say " 2. Create tokens via ActiveAdmin:" + say " Log in to /admin and visit 'MCP Tokens' under Settings" + say "" + say " 3. Connect Claude Code with your token:" + say " claude mcp add --transport http \\", :cyan + say " --header 'Authorization: Bearer YOUR_TOKEN' \\", :cyan + say " #{app_name} http://localhost:3000/mcp/", :cyan + else + say "Connect Claude Code:" + say "" + 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" + end + say "" end From 2b6dada18147691070151c34a42ff3397a693ee1 Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Tue, 17 Mar 2026 16:33:58 +0000 Subject: [PATCH 09/14] docs: add authentication setup guide to README --- README.md | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6f4b0e8..ec9af5a 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,66 @@ rails generate active_admin_mcp:install The MCP server is automatically mounted at `/mcp`. +## Authentication + +To protect your MCP endpoint with API token authentication: + +```bash +rails generate active_admin_mcp:install --auth +rails db:migrate +``` + +This will: +- Create the `mcp_api_tokens` table +- Add an "MCP Tokens" page to your ActiveAdmin panel +- Enable token authentication in the initializer + +### 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 + +### Connecting with a Token + +```bash +claude mcp add --transport http \ + --header 'Authorization: Bearer YOUR_TOKEN' \ + my-app http://localhost:3000/mcp/ +``` + +Or in `.mcp.json`: + +```json +{ + "mcpServers": { + "my-app": { + "type": "http", + "url": "http://localhost:3000/mcp/", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + } + } + } +} +``` + +### Configuration + +The initializer at `config/initializers/active_admin_mcp.rb`: + +```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 | + ## Usage with Claude Code ```bash @@ -64,7 +124,6 @@ Find active posts from last week ## Future Ideas - [ ] SSE transport support for streaming -- [ ] Authentication (API token, HTTP Basic) - [ ] Configurable resource allowlist - [ ] Write operations (create, update, delete) - [ ] Custom tool definitions per resource From ed53353e02bbc3aefd87c579255c17806b6885be Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Tue, 17 Mar 2026 17:39:24 +0000 Subject: [PATCH 10/14] feat: add configurable mount_path, current_user_method, menu_parent, admin_path and auth CLI options - mount_path config option (default: /mcp) - current_user_method config option (default: current_admin_user) - menu_parent config option (default: nil) - --auth accepts method name (e.g., devise_token) instead of boolean - --admin-path option for ActiveAdmin page location - Fix page_action routes for create/destroy - Migration guards against existing table - Use prepend for engine route mounting - Remove foreign key from migration Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 20 +++++++++++-- .../specs/2026-03-17-api-token-auth-design.md | 23 +++++++++------ lib/active_admin_mcp/configuration.rb | 5 +++- lib/active_admin_mcp/engine.rb | 4 +-- .../install/install_generator.rb | 28 +++++++++++-------- .../install/templates/initializer.rb | 9 ++++++ .../install/templates/mcp_api_tokens.rb | 26 ++++++++--------- .../install/templates/migration.rb.erb | 4 ++- 8 files changed, 80 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index ec9af5a..96d58a1 100644 --- a/README.md +++ b/README.md @@ -30,15 +30,28 @@ The MCP server is automatically mounted at `/mcp`. To protect your MCP endpoint with API token authentication: ```bash -rails generate active_admin_mcp:install --auth +rails generate active_admin_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 +- 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: + +```bash +rails generate active_admin_mcp:install --auth devise_token --admin-path app/admin/mcp +``` + ### Managing Tokens 1. Log in to your ActiveAdmin panel (`/admin`) @@ -84,6 +97,9 @@ end |--------|---------|-------------| | `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 | ## Usage with Claude Code diff --git a/docs/superpowers/specs/2026-03-17-api-token-auth-design.md b/docs/superpowers/specs/2026-03-17-api-token-auth-design.md index 0702b0b..07d0dc2 100644 --- a/docs/superpowers/specs/2026-03-17-api-token-auth-design.md +++ b/docs/superpowers/specs/2026-03-17-api-token-auth-design.md @@ -31,19 +31,19 @@ Add opt-in API token authentication to the MCP endpoint. Users generate tokens v ```ruby ActiveAdminMcp.configure do |config| - config.authentication_enabled = true + config.authentication_method = :devise_token config.user_class = "User" # default end ``` -- `authentication_enabled` — defaults to `false` (opt-in). When false, MCP endpoint is open as today. +- `authentication_method` — defaults to `nil` (no auth, MCP endpoint is open as today). Set to `:devise_token` to enable Bearer token authentication backed by the Devise user model. - `user_class` — string name of the Devise model class. Defaults to `"User"`. Used for the `belongs_to` association on `ApiToken`. ## Authentication Flow In `McpController`: -1. `before_action :authenticate_mcp_token!` — only runs when `authentication_enabled` is `true` +1. `before_action :authenticate_mcp_token!` — only runs when `authentication_method` is `:devise_token` 2. Extracts token from `Authorization: Bearer ` header 3. Looks up `ApiToken` by `token_digest: Digest::SHA256.hexdigest(raw_token)` 4. **Found:** sets `current_mcp_user` accessor, touches `last_used_at` (throttled — only if nil or older than 5 minutes), proceeds @@ -65,12 +65,18 @@ This is an ActiveAdmin **Page** (not a Resource), so it will not be discovered b ## Generator & Migration -Updated `rails generate active_admin_mcp:install` will: +**Base install** (`rails generate active_admin_mcp:install`): -1. Copy migration to create `mcp_api_tokens` table (with unique index on `token_digest`, index on `user_id`) -2. Copy initializer template to `config/initializers/active_admin_mcp.rb` +1. Copy initializer template to `config/initializers/active_admin_mcp.rb` (auth commented out by default) +2. Print post-install instructions + +**With authentication** (`rails generate active_admin_mcp:install --auth`): + +1. Everything from base install, plus: +2. Copy migration to create `mcp_api_tokens` table (with unique index on `token_digest`, index on `user_id`) 3. Copy ActiveAdmin page template to `app/admin/mcp_api_tokens.rb` -4. Print post-install instructions +4. Uncomment `authentication_method = :devise_token` in the initializer +5. Print auth-specific post-install instructions (run migrations, etc.) The generator declares `source_root File.expand_path("templates", __dir__)` to resolve template paths. @@ -87,7 +93,8 @@ The generator declares `source_root File.expand_path("templates", __dir__)` to r - `lib/active_admin_mcp.rb` — configuration module - `app/controllers/active_admin_mcp/mcp_controller.rb` — `before_action` auth check with missing-table rescue -- `lib/generators/active_admin_mcp/install/install_generator.rb` — add `source_root`, copy migration, initializer, admin page +- `lib/generators/active_admin_mcp/install/install_generator.rb` — add `source_root`, `--auth` flag, conditional migration/admin page copy +- `README.md` — document authentication setup with `--auth` flag and configuration ### No new gem dependencies diff --git a/lib/active_admin_mcp/configuration.rb b/lib/active_admin_mcp/configuration.rb index c7f3540..d9c620a 100644 --- a/lib/active_admin_mcp/configuration.rb +++ b/lib/active_admin_mcp/configuration.rb @@ -2,11 +2,14 @@ module ActiveAdminMcp class Configuration - attr_accessor :authentication_method, :user_class + attr_accessor :authentication_method, :user_class, :current_user_method, :menu_parent, :mount_path def initialize @authentication_method = nil @user_class = "User" + @current_user_method = :current_admin_user + @menu_parent = nil + @mount_path = "/mcp" end def authentication_enabled? diff --git a/lib/active_admin_mcp/engine.rb b/lib/active_admin_mcp/engine.rb index fdfb38b..df67a08 100644 --- a/lib/active_admin_mcp/engine.rb +++ b/lib/active_admin_mcp/engine.rb @@ -5,8 +5,8 @@ class Engine < ::Rails::Engine isolate_namespace ActiveAdminMcp initializer "active_admin_mcp.mount" do |app| - app.routes.append do - mount ActiveAdminMcp::Engine => "/mcp" + app.routes.prepend do + mount ActiveAdminMcp::Engine => ActiveAdminMcp.config.mount_path end end end diff --git a/lib/generators/active_admin_mcp/install/install_generator.rb b/lib/generators/active_admin_mcp/install/install_generator.rb index 326e307..fe1325c 100644 --- a/lib/generators/active_admin_mcp/install/install_generator.rb +++ b/lib/generators/active_admin_mcp/install/install_generator.rb @@ -10,31 +10,33 @@ class InstallGenerator < Rails::Generators::Base source_root File.expand_path("templates", __dir__) - class_option :auth, type: :boolean, default: false, - desc: "Generate authentication support (migration, admin page, config)" + class_option :auth, type: :string, default: nil, + desc: "Authentication method (e.g., devise_token)" + class_option :admin_path, type: :string, default: "app/admin", + desc: "Path for ActiveAdmin page file" def copy_initializer template "initializer.rb", "config/initializers/active_admin_mcp.rb" end def copy_migration - return unless options[:auth] + return unless auth_method migration_template "migration.rb.erb", "db/migrate/create_mcp_api_tokens.rb" end def copy_admin_page - return unless options[:auth] + return unless auth_method - copy_file "mcp_api_tokens.rb", "app/admin/mcp_api_tokens.rb" + copy_file "mcp_api_tokens.rb", File.join(options[:admin_path], "mcp_api_tokens.rb") end - def uncomment_auth_config - return unless options[:auth] + def set_auth_config + return unless auth_method gsub_file "config/initializers/active_admin_mcp.rb", "# config.authentication_method = :devise_token", - "config.authentication_method = :devise_token" + "config.authentication_method = :#{auth_method}" end def show_instructions @@ -46,8 +48,8 @@ def show_instructions say "Your MCP server is available at: /mcp" say "" - if options[:auth] - say "Authentication enabled! Next steps:", :yellow + if auth_method + say "Authentication (#{auth_method}) enabled! Next steps:", :yellow say "" say " 1. Run migrations:" say " rails db:migrate" @@ -65,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" + say " rails generate active_admin_mcp:install --auth devise_token" end say "" @@ -73,6 +75,10 @@ def show_instructions private + def auth_method + options[:auth] + end + def app_name Rails.application.class.module_parent_name.underscore.dasherize rescue StandardError diff --git a/lib/generators/active_admin_mcp/install/templates/initializer.rb b/lib/generators/active_admin_mcp/install/templates/initializer.rb index 38b487f..311c8f9 100644 --- a/lib/generators/active_admin_mcp/install/templates/initializer.rb +++ b/lib/generators/active_admin_mcp/install/templates/initializer.rb @@ -9,4 +9,13 @@ # The Devise model class used for authentication. # config.user_class = "User" + + # The controller method that returns the current user. + # config.current_user_method = :current_admin_user + + # Parent menu for the MCP Tokens page in ActiveAdmin. + # config.menu_parent = "Settings" + + # Path where the MCP server is mounted. + # config.mount_path = "/mcp" end diff --git a/lib/generators/active_admin_mcp/install/templates/mcp_api_tokens.rb b/lib/generators/active_admin_mcp/install/templates/mcp_api_tokens.rb index 18cf969..1a3b761 100644 --- a/lib/generators/active_admin_mcp/install/templates/mcp_api_tokens.rb +++ b/lib/generators/active_admin_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: "Settings", priority: 100 + menu label: "MCP Tokens", parent: ActiveAdminMcp.config.menu_parent, priority: 100 content do - @tokens = ActiveAdminMcp::ApiToken.where(user: current_admin_user).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 @@ -19,7 +19,7 @@ column(:created_at) { |t| l(t.created_at, format: :long) } column(:last_used_at) { |t| t.last_used_at ? l(t.last_used_at, format: :long) : "Never" } column "Actions" do |token| - link_to "Revoke", admin_mcp_api_tokens_path(token_id: token.id), + link_to "Revoke", admin_mcp_api_tokens_destroy_path(token_id: token.id), method: :delete, data: { confirm: "Revoke token '#{token.name}'?" }, class: "button small" @@ -32,30 +32,28 @@ end panel "Create New Token" do - active_admin_form_for :mcp_token, url: admin_mcp_api_tokens_path, method: :post do |f| - f.inputs do - f.input :name, as: :string, label: "Token Name", hint: "A label to identify this token (e.g., 'Claude Code laptop')" - end - f.actions do - f.action :submit, label: "Generate Token" - end + form action: admin_mcp_api_tokens_create_path, method: :post do + input type: :hidden, name: :authenticity_token, value: form_authenticity_token + label "Token Name", for: :mcp_token_name + input type: :text, name: "mcp_token[name]", id: :mcp_token_name, placeholder: "e.g., Claude Code laptop" + input type: :submit, value: "Generate Token" end end end page_action :create, method: :post do token = ActiveAdminMcp::ApiToken.create!( - user: current_admin_user, + user: send(ActiveAdminMcp.config.current_user_method), name: params[:mcp_token][:name].presence || "Unnamed token" ) flash[:mcp_raw_token] = token.raw_token - redirect_to admin_mcp_api_tokens_path + redirect_to admin_mcp_api_tokens_path() end page_action :destroy, method: :delete do - token = ActiveAdminMcp::ApiToken.where(user: current_admin_user).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 + redirect_to admin_mcp_api_tokens_path() end end diff --git a/lib/generators/active_admin_mcp/install/templates/migration.rb.erb b/lib/generators/active_admin_mcp/install/templates/migration.rb.erb index 6c3fd3c..b65c5bf 100644 --- a/lib/generators/active_admin_mcp/install/templates/migration.rb.erb +++ b/lib/generators/active_admin_mcp/install/templates/migration.rb.erb @@ -2,8 +2,10 @@ class CreateMcpApiTokens < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] def change + return if table_exists?(:mcp_api_tokens) + create_table :mcp_api_tokens do |t| - t.references :user, null: false, foreign_key: true + t.references :user, null: false t.string :token_digest, null: false t.string :name t.datetime :last_used_at From 2c4395ecb133951a6e017b0049dc54a553fbf43b Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Tue, 17 Mar 2026 17:41:34 +0000 Subject: [PATCH 11/14] Delete docs directory --- .../plans/2026-03-17-api-token-auth.md | 639 ------------------ .../specs/2026-03-17-api-token-auth-design.md | 108 --- 2 files changed, 747 deletions(-) delete mode 100644 docs/superpowers/plans/2026-03-17-api-token-auth.md delete mode 100644 docs/superpowers/specs/2026-03-17-api-token-auth-design.md diff --git a/docs/superpowers/plans/2026-03-17-api-token-auth.md b/docs/superpowers/plans/2026-03-17-api-token-auth.md deleted file mode 100644 index c6f9557..0000000 --- a/docs/superpowers/plans/2026-03-17-api-token-auth.md +++ /dev/null @@ -1,639 +0,0 @@ -# API Token Authentication Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add opt-in Bearer token authentication to the MCP endpoint, backed by Devise user model with hashed tokens and an ActiveAdmin management page. - -**Architecture:** Configuration module on `ActiveAdminMcp` with `authentication_method` and `user_class` settings. `ApiToken` model stores SHA256-hashed tokens linked to users. `McpController` gains a conditional `before_action` that validates Bearer tokens. Install generator gets `--auth` flag to conditionally scaffold migration + admin page. - -**Tech Stack:** Ruby/Rails engine, ActiveAdmin, Devise (host app), SHA256 (stdlib), SecureRandom (stdlib) - ---- - -## File Structure - -| File | Action | Responsibility | -|------|--------|---------------| -| `lib/active_admin_mcp.rb` | Modify | Configuration module (`configure`, `config`) | -| `lib/active_admin_mcp/configuration.rb` | Create | Configuration class with `authentication_method` and `user_class` | -| `app/models/active_admin_mcp/api_token.rb` | Create | Token model — generation, hashing, lookup | -| `app/controllers/active_admin_mcp/mcp_controller.rb` | Modify | `before_action` auth check | -| `lib/generators/active_admin_mcp/install/install_generator.rb` | Modify | `--auth` flag, template copying | -| `lib/generators/active_admin_mcp/install/templates/initializer.rb` | Create | Initializer template | -| `lib/generators/active_admin_mcp/install/templates/migration.rb.erb` | Create | Migration template | -| `lib/generators/active_admin_mcp/install/templates/mcp_api_tokens.rb` | Create | ActiveAdmin page template | -| `README.md` | Modify | Auth documentation | - ---- - -### Task 1: Configuration Module - -**Files:** -- Create: `lib/active_admin_mcp/configuration.rb` -- Modify: `lib/active_admin_mcp.rb` - -- [ ] **Step 1: Create the Configuration class** - -Create `lib/active_admin_mcp/configuration.rb`: - -```ruby -# frozen_string_literal: true - -module ActiveAdminMcp - class Configuration - attr_accessor :authentication_method, :user_class - - def initialize - @authentication_method = nil - @user_class = "User" - end - - def authentication_enabled? - authentication_method == :devise_token - end - end -end -``` - -- [ ] **Step 2: Add configure/config to main module** - -Modify `lib/active_admin_mcp.rb` to add: - -```ruby -# 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/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 -``` - -- [ ] **Step 3: Commit** - -```bash -git add lib/active_admin_mcp/configuration.rb lib/active_admin_mcp.rb -git commit -m "feat: add configuration module with authentication_method and user_class" -``` - ---- - -### Task 2: ApiToken Model - -**Files:** -- Create: `app/models/active_admin_mcp/api_token.rb` - -- [ ] **Step 1: Create the ApiToken model** - -Create `app/models/active_admin_mcp/api_token.rb`: - -```ruby -# frozen_string_literal: true - -require "digest" -require "securerandom" - -module ActiveAdminMcp - class ApiToken < ActiveRecord::Base - self.table_name = "mcp_api_tokens" - - belongs_to :user, class_name: ActiveAdminMcp.config.user_class - - attr_accessor :raw_token - - validates :token_digest, presence: true, uniqueness: true - validates :user_id, presence: true - - before_validation :generate_token, on: :create - - LAST_USED_THROTTLE = 5.minutes - - def self.find_by_raw_token(raw_token) - return nil if raw_token.blank? - - find_by(token_digest: digest(raw_token)) - end - - def self.digest(raw_token) - Digest::SHA256.hexdigest(raw_token) - end - - def touch_last_used! - return if last_used_at.present? && last_used_at > LAST_USED_THROTTLE.ago - - update_column(:last_used_at, Time.current) - end - - private - - def generate_token - self.raw_token = "aamcp_#{SecureRandom.hex(32)}" - self.token_digest = self.class.digest(raw_token) - end - end -end -``` - -- [ ] **Step 2: Commit** - -```bash -git add app/models/active_admin_mcp/api_token.rb -git commit -m "feat: add ApiToken model with SHA256 hashing and throttled last_used_at" -``` - ---- - -### Task 3: Controller Authentication - -**Files:** -- Modify: `app/controllers/active_admin_mcp/mcp_controller.rb` - -- [ ] **Step 1: Add before_action auth to McpController** - -Replace the full content of `app/controllers/active_admin_mcp/mcp_controller.rb`: - -```ruby -# frozen_string_literal: true - -module ActiveAdminMcp - class McpController < ActionController::API - before_action :authenticate_mcp_token! - - attr_reader :current_mcp_user - - def call - request_body = JSON.parse(request.body.read) - response = RequestHandler.new.handle(request_body) - - response ? render(json: response) : head(:no_content) - rescue JSON::ParserError => e - render json: { jsonrpc: "2.0", error: { code: -32_700, message: e.message } }, status: :bad_request - end - - private - - def authenticate_mcp_token! - return unless ActiveAdminMcp.config.authentication_enabled? - - token = extract_bearer_token - unless token - render json: jsonrpc_error(-32_000, "Unauthorized"), status: :unauthorized - return - end - - api_token = ApiToken.find_by_raw_token(token) - unless api_token - render json: jsonrpc_error(-32_000, "Unauthorized"), status: :unauthorized - return - end - - @current_mcp_user = api_token.user - api_token.touch_last_used! - rescue ActiveRecord::StatementInvalid - render json: jsonrpc_error(-32_000, "Authentication not configured — run migrations"), - status: :internal_server_error - end - - def extract_bearer_token - header = request.headers["Authorization"] - return nil unless header&.start_with?("Bearer ") - - header.delete_prefix("Bearer ") - end - - def jsonrpc_error(code, message) - { jsonrpc: "2.0", id: nil, error: { code: code, message: message } } - end - end -end -``` - -- [ ] **Step 2: Commit** - -```bash -git add app/controllers/active_admin_mcp/mcp_controller.rb -git commit -m "feat: add Bearer token authentication to MCP controller" -``` - ---- - -### Task 4: Generator Templates - -**Files:** -- Create: `lib/generators/active_admin_mcp/install/templates/initializer.rb` -- Create: `lib/generators/active_admin_mcp/install/templates/migration.rb.erb` -- Create: `lib/generators/active_admin_mcp/install/templates/mcp_api_tokens.rb` - -- [ ] **Step 1: Create initializer template** - -Create `lib/generators/active_admin_mcp/install/templates/initializer.rb`: - -```ruby -# frozen_string_literal: true - -ActiveAdminMcp.configure do |config| - # Uncomment to enable API token authentication. - # Requires running the auth migration first: - # rails generate active_admin_mcp:install --auth - # - # config.authentication_method = :devise_token - - # The Devise model class used for authentication. - # config.user_class = "User" -end -``` - -- [ ] **Step 2: Create migration template** - -Create `lib/generators/active_admin_mcp/install/templates/migration.rb.erb`: - -```erb -# frozen_string_literal: true - -class CreateMcpApiTokens < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] - def change - create_table :mcp_api_tokens do |t| - t.references :user, null: false, foreign_key: true - t.string :token_digest, null: false - t.string :name - t.datetime :last_used_at - - t.timestamps - end - - add_index :mcp_api_tokens, :token_digest, unique: true - end -end -``` - -- [ ] **Step 3: Create ActiveAdmin page template** - -Create `lib/generators/active_admin_mcp/install/templates/mcp_api_tokens.rb`: - -```ruby -# frozen_string_literal: true - -ActiveAdmin.register_page "MCP API Tokens" do - menu label: "MCP Tokens", parent: "Settings", priority: 100 - - content do - @tokens = ActiveAdminMcp::ApiToken.where(user: current_admin_user).order(created_at: :desc) - - if flash[:mcp_raw_token] - panel "New Token Created", class: "mcp-token-created" do - para "Copy this token now — it will not be shown again:" - pre flash[:mcp_raw_token], class: "mcp-raw-token" - end - end - - panel "Your MCP API Tokens" do - table_for @tokens do - column :name - column(:created_at) { |t| l(t.created_at, format: :long) } - column(:last_used_at) { |t| t.last_used_at ? l(t.last_used_at, format: :long) : "Never" } - column "Actions" do |token| - link_to "Revoke", admin_mcp_api_tokens_path(token_id: token.id), - method: :delete, - data: { confirm: "Revoke token '#{token.name}'?" }, - class: "button small" - end - end - - if @tokens.empty? - para "No tokens yet. Create one to authenticate MCP clients." - end - end - - panel "Create New Token" do - active_admin_form_for :mcp_token, url: admin_mcp_api_tokens_path, method: :post do |f| - f.inputs do - f.input :name, as: :string, label: "Token Name", hint: "A label to identify this token (e.g., 'Claude Code laptop')" - end - f.actions do - f.action :submit, label: "Generate Token" - end - end - end - end - - page_action :create, method: :post do - token = ActiveAdminMcp::ApiToken.create!( - user: current_admin_user, - name: params[:mcp_token][:name].presence || "Unnamed token" - ) - flash[:mcp_raw_token] = token.raw_token - redirect_to admin_mcp_api_tokens_path - end - - page_action :destroy, method: :delete do - token = ActiveAdminMcp::ApiToken.where(user: current_admin_user).find(params[:token_id]) - token.destroy! - flash[:notice] = "Token revoked." - redirect_to admin_mcp_api_tokens_path - end -end -``` - -- [ ] **Step 4: Commit** - -```bash -git add lib/generators/active_admin_mcp/install/templates/ -git commit -m "feat: add generator templates for initializer, migration, and admin page" -``` - ---- - -### Task 5: Update Install Generator - -**Files:** -- Modify: `lib/generators/active_admin_mcp/install/install_generator.rb` - -- [ ] **Step 1: Rewrite the install generator with --auth flag** - -Replace the full content of `lib/generators/active_admin_mcp/install/install_generator.rb`: - -```ruby -# frozen_string_literal: true - -require "rails/generators" -require "rails/generators/active_record" - -module ActiveAdminMcp - module Generators - class InstallGenerator < Rails::Generators::Base - include ActiveRecord::Generators::Migration - - source_root File.expand_path("templates", __dir__) - - class_option :auth, type: :boolean, default: false, - desc: "Generate authentication support (migration, admin page, config)" - - def copy_initializer - template "initializer.rb", "config/initializers/active_admin_mcp.rb" - end - - def copy_migration - return unless options[:auth] - - migration_template "migration.rb.erb", "db/migrate/create_mcp_api_tokens.rb" - end - - def copy_admin_page - return unless options[:auth] - - copy_file "mcp_api_tokens.rb", "app/admin/mcp_api_tokens.rb" - end - - def uncomment_auth_config - return unless options[:auth] - - gsub_file "config/initializers/active_admin_mcp.rb", - "# config.authentication_method = :devise_token", - "config.authentication_method = :devise_token" - end - - def show_instructions - say "" - say "=" * 60, :green - say " ActiveAdminMcp installed!", :green - say "=" * 60, :green - say "" - say "Your MCP server is available at: /mcp" - say "" - - if options[:auth] - say "Authentication enabled! Next steps:", :yellow - say "" - say " 1. Run migrations:" - say " rails db:migrate" - say "" - say " 2. Create tokens via ActiveAdmin:" - say " Log in to /admin and visit 'MCP Tokens' under Settings" - say "" - say " 3. Connect Claude Code with your token:" - say " claude mcp add --transport http \\", :cyan - say " --header 'Authorization: Bearer YOUR_TOKEN' \\", :cyan - say " #{app_name} http://localhost:3000/mcp/", :cyan - else - say "Connect Claude Code:" - say "" - 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" - end - - say "" - end - - private - - def app_name - Rails.application.class.module_parent_name.underscore.dasherize - rescue StandardError - "my-app" - end - end - end -end -``` - -- [ ] **Step 2: Commit** - -```bash -git add lib/generators/active_admin_mcp/install/install_generator.rb -git commit -m "feat: update install generator with --auth flag for conditional auth setup" -``` - ---- - -### Task 6: Update README - -**Files:** -- Modify: `README.md` - -- [ ] **Step 1: Update README with authentication docs** - -Replace the full `README.md` with: - -```markdown -# ActiveAdminMcp - -> **Status: Experimental / WIP** - -Minimal MCP (Model Context Protocol) server for Rails apps with ActiveAdmin. - -## Tested Clients - -- **Claude Code** (Anthropic) - HTTP transport - -## Installation - -Add to your Gemfile: - -```ruby -gem "active_admin_mcp" -``` - -Then run: - -```bash -bundle install -rails generate active_admin_mcp:install -``` - -The MCP server is automatically mounted at `/mcp`. - -## Authentication - -To protect your MCP endpoint with API token authentication: - -```bash -rails generate active_admin_mcp:install --auth -rails db:migrate -``` - -This will: -- Create the `mcp_api_tokens` table -- Add an "MCP Tokens" page to your ActiveAdmin panel -- Enable token authentication in the initializer - -### 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 - -### Connecting with a Token - -```bash -claude mcp add --transport http \ - --header 'Authorization: Bearer YOUR_TOKEN' \ - my-app http://localhost:3000/mcp/ -``` - -Or in `.mcp.json`: - -```json -{ - "mcpServers": { - "my-app": { - "type": "http", - "url": "http://localhost:3000/mcp/", - "headers": { - "Authorization": "Bearer YOUR_TOKEN" - } - } - } -} -``` - -### Configuration - -The initializer at `config/initializers/active_admin_mcp.rb`: - -```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 | - -## Usage with Claude Code - -```bash -claude mcp add --transport http my-app http://localhost:3000/mcp/ -``` - -Or add to your `.mcp.json`: - -```json -{ - "mcpServers": { - "my-app": { - "type": "http", - "url": "http://localhost:3000/mcp/" - } - } -} -``` - -## Available Tools - -| Tool | Description | -|------|-------------| -| `list_resources` | List all ActiveAdmin resources with their attributes | -| `query` | Query a resource using Ransack syntax | - -### Query Examples - -``` -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" }) -``` - -## Future Ideas - -- [ ] SSE transport support for streaming -- [ ] Configurable resource allowlist -- [ ] Write operations (create, update, delete) -- [ ] Custom tool definitions per resource -- [ ] Rate limiting - -## License - -MIT -``` - -- [ ] **Step 2: Commit** - -```bash -git add README.md -git commit -m "docs: add authentication setup guide to README" -``` - ---- - -### Task 7: Final Verification - -- [ ] **Step 1: Verify all files exist** - -Run: `find /Users/lloyd/Code/active_admin_mcp -name "*.rb" -path "*/active_admin_mcp/*" | sort` - -Expected new files present: -- `app/models/active_admin_mcp/api_token.rb` -- `lib/active_admin_mcp/configuration.rb` -- `lib/generators/active_admin_mcp/install/templates/initializer.rb` -- `lib/generators/active_admin_mcp/install/templates/mcp_api_tokens.rb` - -And: `find /Users/lloyd/Code/active_admin_mcp -name "*.erb" | sort` - -Expected: `lib/generators/active_admin_mcp/install/templates/migration.rb.erb` - -- [ ] **Step 2: Verify Ruby syntax on all files** - -Run: `for f in app/models/active_admin_mcp/api_token.rb lib/active_admin_mcp/configuration.rb lib/active_admin_mcp.rb app/controllers/active_admin_mcp/mcp_controller.rb lib/generators/active_admin_mcp/install/install_generator.rb lib/generators/active_admin_mcp/install/templates/initializer.rb lib/generators/active_admin_mcp/install/templates/mcp_api_tokens.rb; do ruby -c "$f"; done` - -Expected: `Syntax OK` for each file diff --git a/docs/superpowers/specs/2026-03-17-api-token-auth-design.md b/docs/superpowers/specs/2026-03-17-api-token-auth-design.md deleted file mode 100644 index 07d0dc2..0000000 --- a/docs/superpowers/specs/2026-03-17-api-token-auth-design.md +++ /dev/null @@ -1,108 +0,0 @@ -# API Token Authentication for ActiveAdmin MCP - -## Overview - -Add opt-in API token authentication to the MCP endpoint. Users generate tokens via an ActiveAdmin page, and MCP clients send them as Bearer tokens in the Authorization header. Tokens are hashed (SHA256) at rest. Authentication piggybacks on the host app's existing Devise user model. - -## Data Model - -### `mcp_api_tokens` table - -| Column | Type | Notes | -|---|---|---| -| `id` | bigint | PK | -| `user_id` | bigint | FK to Devise user model, indexed, not null | -| `token_digest` | string | SHA256 hash of raw token, not null, unique index | -| `name` | string | User-provided label (e.g., "Claude Code laptop"), nullable | -| `last_used_at` | datetime | Updated on each authenticated request (throttled to once per 5 minutes), nullable | -| `created_at` | datetime | Rails timestamp | -| `updated_at` | datetime | Rails timestamp | - -### `ActiveAdminMcp::ApiToken` model - -- `belongs_to :user` — user class is configurable (defaults to `"User"`) -- On create: generates `SecureRandom.hex(32)` raw token with `aamcp_` prefix for identifiability, stores `Digest::SHA256.hexdigest(raw_token)` as `token_digest` -- Raw token exposed via transient `attr_accessor :raw_token`, accessible only once after creation, never persisted -- Lookup: `find_by(token_digest: Digest::SHA256.hexdigest(provided_token))` - -**Security note:** Token lookup uses a database `WHERE` equality on the SHA256 digest. This is timing-safe because the comparison is on an irreversible hash — an attacker cannot exploit timing differences to recover the raw token from its digest. No additional constant-time comparison is needed for the DB lookup path. If any future code adds in-Ruby comparison of raw tokens, it must use `ActiveSupport::SecurityUtils.secure_compare`. - -## Configuration - -```ruby -ActiveAdminMcp.configure do |config| - config.authentication_method = :devise_token - config.user_class = "User" # default -end -``` - -- `authentication_method` — defaults to `nil` (no auth, MCP endpoint is open as today). Set to `:devise_token` to enable Bearer token authentication backed by the Devise user model. -- `user_class` — string name of the Devise model class. Defaults to `"User"`. Used for the `belongs_to` association on `ApiToken`. - -## Authentication Flow - -In `McpController`: - -1. `before_action :authenticate_mcp_token!` — only runs when `authentication_method` is `:devise_token` -2. Extracts token from `Authorization: Bearer ` header -3. Looks up `ApiToken` by `token_digest: Digest::SHA256.hexdigest(raw_token)` -4. **Found:** sets `current_mcp_user` accessor, touches `last_used_at` (throttled — only if nil or older than 5 minutes), proceeds -5. **Not found / missing header:** returns JSON-RPC error (`-32000`, "Unauthorized") with HTTP 401 -6. **Table missing** (`ActiveRecord::StatementInvalid`): rescued in `before_action`, returns JSON-RPC error (`-32000`, "Authentication not configured — run migrations") with HTTP 500 - -Stateless — no cookies or sessions. Works for both POST requests and future GET-based SSE transport, since `before_action` applies to all controller actions. - -## ActiveAdmin Token Management Page - -A template file copied to `app/admin/mcp_api_tokens.rb` during installation. ActiveAdmin auto-loads files in `app/admin/`, so no programmatic registration method is needed. - -This is an ActiveAdmin **Page** (not a Resource), so it will not be discovered by `ResourceRegistry` and `token_digest` will not be exposed through MCP tools. - -- **Index view:** Table of the current user's tokens — name, created at, last used at, revoke button. Uses `current_admin_user` (Devise's standard helper available in ActiveAdmin controllers) to scope queries. -- **Create action:** Form with `name` field. On submit, generates token and displays raw token once in a flash/panel with copy warning. User warned it won't be shown again. Uses standard ActiveAdmin form submissions (CSRF handled automatically by ActionController::Base). -- **Revoke action:** Deletes token record with confirmation prompt. -- **Scoped to current user** — admins only see/manage their own tokens. - -## Generator & Migration - -**Base install** (`rails generate active_admin_mcp:install`): - -1. Copy initializer template to `config/initializers/active_admin_mcp.rb` (auth commented out by default) -2. Print post-install instructions - -**With authentication** (`rails generate active_admin_mcp:install --auth`): - -1. Everything from base install, plus: -2. Copy migration to create `mcp_api_tokens` table (with unique index on `token_digest`, index on `user_id`) -3. Copy ActiveAdmin page template to `app/admin/mcp_api_tokens.rb` -4. Uncomment `authentication_method = :devise_token` in the initializer -5. Print auth-specific post-install instructions (run migrations, etc.) - -The generator declares `source_root File.expand_path("templates", __dir__)` to resolve template paths. - -## File Changes - -### New files - -- `app/models/active_admin_mcp/api_token.rb` — token model -- `lib/generators/active_admin_mcp/install/templates/migration.rb` — migration template -- `lib/generators/active_admin_mcp/install/templates/initializer.rb` — config initializer template -- `lib/generators/active_admin_mcp/install/templates/mcp_api_tokens.rb` — ActiveAdmin page template - -### Modified files - -- `lib/active_admin_mcp.rb` — configuration module -- `app/controllers/active_admin_mcp/mcp_controller.rb` — `before_action` auth check with missing-table rescue -- `lib/generators/active_admin_mcp/install/install_generator.rb` — add `source_root`, `--auth` flag, conditional migration/admin page copy -- `README.md` — document authentication setup with `--auth` flag and configuration - -### No new gem dependencies - -SHA256 and SecureRandom are Ruby stdlib. - -## Out of Scope - -- Cookie/session handling -- Token expiry -- Rate limiting -- Scoped permissions per token From 6f9594061dfc74c577f8c63681d2e3ccff488d10 Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Wed, 18 Mar 2026 10:49:36 +0000 Subject: [PATCH 12/14] Add configurable mount_strategy option for route registration Adds a mount_strategy configuration option that controls how the engine mounts its routes. Accepts :prepend (default), :append, or :none. This allows host applications with route constraints (e.g. hostname-based admin routing) to mount the engine manually inside their constraint blocks instead of relying on the auto-prepend which places the mount outside any constraints. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/active_admin_mcp/configuration.rb | 13 +++++++++++++ lib/active_admin_mcp/engine.rb | 11 +++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/active_admin_mcp/configuration.rb b/lib/active_admin_mcp/configuration.rb index d9c620a..3387f1b 100644 --- a/lib/active_admin_mcp/configuration.rb +++ b/lib/active_admin_mcp/configuration.rb @@ -2,14 +2,27 @@ module ActiveAdminMcp class Configuration + MOUNT_STRATEGIES = %i[prepend append none].freeze + attr_accessor :authentication_method, :user_class, :current_user_method, :menu_parent, :mount_path + attr_reader :mount_strategy + def initialize @authentication_method = nil @user_class = "User" @current_user_method = :current_admin_user @menu_parent = nil @mount_path = "/mcp" + @mount_strategy = :prepend + end + + def mount_strategy=(strategy) + unless MOUNT_STRATEGIES.include?(strategy) + raise ArgumentError, "Invalid mount strategy: #{strategy}. Must be one of: #{MOUNT_STRATEGIES.join(', ')}" + end + + @mount_strategy = strategy end def authentication_enabled? diff --git a/lib/active_admin_mcp/engine.rb b/lib/active_admin_mcp/engine.rb index df67a08..df0ddac 100644 --- a/lib/active_admin_mcp/engine.rb +++ b/lib/active_admin_mcp/engine.rb @@ -5,8 +5,15 @@ class Engine < ::Rails::Engine isolate_namespace ActiveAdminMcp initializer "active_admin_mcp.mount" do |app| - app.routes.prepend do - mount ActiveAdminMcp::Engine => ActiveAdminMcp.config.mount_path + 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 From bf3c92fc6dc859d4ffa69175ff3a7f30e8df1be3 Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Wed, 18 Mar 2026 10:52:35 +0000 Subject: [PATCH 13/14] Document mount_strategy option in README Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index 96d58a1..1ff4c4b 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,38 @@ rails generate active_admin_mcp:install The MCP server is automatically mounted at `/mcp`. +## Route Mounting + +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. + +The `mount_strategy` option controls how the engine registers its route: + +| 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 | + +### Manual mounting + +If your admin routes are wrapped in constraints, set `mount_strategy` to `:none` and mount the engine inside your route file: + +```ruby +# config/initializers/active_admin_mcp.rb +ActiveAdminMcp.configure do |config| + config.mount_path = "/admin/mcp" + config.mount_strategy = :none +end +``` + +```ruby +# config/routes.rb (or a drawn route file) +constraints AdminConstraint.new do + ActiveAdmin.routes(self) + mount ActiveAdminMcp::Engine => ActiveAdminMcp.config.mount_path +end +``` + ## Authentication To protect your MCP endpoint with API token authentication: @@ -100,6 +132,7 @@ end | `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` | ## Usage with Claude Code From 21cd625ca4381ed7096d6b268f72306155bf18c3 Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Wed, 18 Mar 2026 15:47:31 +0000 Subject: [PATCH 14/14] Add configurable auth_header_name option Allow the HTTP header used for Bearer token authentication to be customised. Defaults to "Authorization" for backwards compatibility. Useful when the application sits behind a reverse proxy (e.g. AWS Verified Access) that strips the standard Authorization header before forwarding requests to the origin. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 28 +++++++++++++++++++ .../active_admin_mcp/mcp_controller.rb | 2 +- lib/active_admin_mcp/configuration.rb | 4 ++- .../install/templates/initializer.rb | 5 ++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1ff4c4b..c54cfb4 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,34 @@ end | `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| + 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": { + "my-app": { + "type": "http", + "url": "https://admin.example.com/admin/mcp/", + "headers": { + "X-MCP-Authorization": "Bearer YOUR_TOKEN" + } + } + } +} +``` ## Usage with Claude Code diff --git a/app/controllers/active_admin_mcp/mcp_controller.rb b/app/controllers/active_admin_mcp/mcp_controller.rb index a36ed6a..44451b6 100644 --- a/app/controllers/active_admin_mcp/mcp_controller.rb +++ b/app/controllers/active_admin_mcp/mcp_controller.rb @@ -40,7 +40,7 @@ def authenticate_mcp_token! end def extract_bearer_token - header = request.headers["Authorization"] + header = request.headers[ActiveAdminMcp.config.auth_header_name] return nil unless header&.start_with?("Bearer ") header.delete_prefix("Bearer ") diff --git a/lib/active_admin_mcp/configuration.rb b/lib/active_admin_mcp/configuration.rb index 3387f1b..17f93b3 100644 --- a/lib/active_admin_mcp/configuration.rb +++ b/lib/active_admin_mcp/configuration.rb @@ -4,7 +4,8 @@ module ActiveAdminMcp class Configuration MOUNT_STRATEGIES = %i[prepend append none].freeze - attr_accessor :authentication_method, :user_class, :current_user_method, :menu_parent, :mount_path + attr_accessor :authentication_method, :user_class, :current_user_method, :menu_parent, :mount_path, + :auth_header_name attr_reader :mount_strategy @@ -15,6 +16,7 @@ def initialize @menu_parent = nil @mount_path = "/mcp" @mount_strategy = :prepend + @auth_header_name = "Authorization" end def mount_strategy=(strategy) diff --git a/lib/generators/active_admin_mcp/install/templates/initializer.rb b/lib/generators/active_admin_mcp/install/templates/initializer.rb index 311c8f9..ad1d5e3 100644 --- a/lib/generators/active_admin_mcp/install/templates/initializer.rb +++ b/lib/generators/active_admin_mcp/install/templates/initializer.rb @@ -18,4 +18,9 @@ # Path where the MCP server is mounted. # config.mount_path = "/mcp" + + # HTTP header used to read the Bearer token from. + # Useful when a reverse proxy (e.g. AWS Verified Access) strips the + # standard Authorization header. + # config.auth_header_name = "Authorization" end