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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 137 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions app/controllers/active_admin_mcp/mcp_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
44 changes: 44 additions & 0 deletions app/models/active_admin_mcp/api_token.rb
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions lib/active_admin_mcp.rb
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions lib/active_admin_mcp/configuration.rb
Original file line number Diff line number Diff line change
@@ -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
11 changes: 9 additions & 2 deletions lib/active_admin_mcp/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading