diff --git a/README.md b/README.md index 6f4b0e8..c54cfb4 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,143 @@ 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: + +```bash +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 (`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`) +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 | +| `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| + 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 ```bash @@ -64,7 +201,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 diff --git a/app/controllers/active_admin_mcp/mcp_controller.rb b/app/controllers/active_admin_mcp/mcp_controller.rb index c4ede10..44451b6 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[ActiveAdminMcp.config.auth_header_name] + 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 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 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..17f93b3 --- /dev/null +++ b/lib/active_admin_mcp/configuration.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module ActiveAdminMcp + class Configuration + MOUNT_STRATEGIES = %i[prepend append none].freeze + + attr_accessor :authentication_method, :user_class, :current_user_method, :menu_parent, :mount_path, + :auth_header_name + + 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 + @auth_header_name = "Authorization" + 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? + authentication_method == :devise_token + end + end +end diff --git a/lib/active_admin_mcp/engine.rb b/lib/active_admin_mcp/engine.rb index fdfb38b..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.append do - mount ActiveAdminMcp::Engine => "/mcp" + 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 diff --git a/lib/generators/active_admin_mcp/install/install_generator.rb b/lib/generators/active_admin_mcp/install/install_generator.rb index 84d8bd9..fe1325c 100644 --- a/lib/generators/active_admin_mcp/install/install_generator.rb +++ b/lib/generators/active_admin_mcp/install/install_generator.rb @@ -1,9 +1,43 @@ # 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: :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 auth_method + + migration_template "migration.rb.erb", "db/migrate/create_mcp_api_tokens.rb" + end + + def copy_admin_page + return unless auth_method + + copy_file "mcp_api_tokens.rb", File.join(options[:admin_path], "mcp_api_tokens.rb") + end + + def set_auth_config + return unless auth_method + + gsub_file "config/initializers/active_admin_mcp.rb", + "# config.authentication_method = :devise_token", + "config.authentication_method = :#{auth_method}" + end def show_instructions say "" @@ -13,14 +47,38 @@ 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 auth_method + say "Authentication (#{auth_method}) 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 devise_token" + end + say "" end 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 new file mode 100644 index 0000000..ad1d5e3 --- /dev/null +++ b/lib/generators/active_admin_mcp/install/templates/initializer.rb @@ -0,0 +1,26 @@ +# 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" + + # 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" + + # 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 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..1a3b761 --- /dev/null +++ b/lib/generators/active_admin_mcp/install/templates/mcp_api_tokens.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +ActiveAdmin.register_page "MCP API Tokens" do + 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) + + 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_destroy_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 + 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: 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() + end + + page_action :destroy, method: :delete do + 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() + 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..b65c5bf --- /dev/null +++ b/lib/generators/active_admin_mcp/install/templates/migration.rb.erb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +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 + 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