A Rails engine that exposes Administrate dashboards over the Model Context Protocol, so an MCP client such as Claude can list, show and search your admin data, and run the write actions you opt in, with the same permissions the admin UI enforces.
- Why
- Features
- Demo
- Requirements
- Installation
- Quick start
- Configuration
- Routes
- Dashboard declarations
- Authentication
- OAuth
- Improving the server from its own use
- Rate limiting
- Admin integration
- Development
- Security
- Contributing
- Changelog
- Licence
Administrate dashboards are built for a person clicking through a browser. This gem reads the same
dashboard declarations, the same Pundit policies and the same scoped queries, and publishes them as
MCP tools, so an LLM client can answer questions about your admin data and, where you allow it, act
on it, without a second implementation of your authorization rules. Nothing in the engine knows
about your application; everything host-specific goes through Administrate::MCP.configure.
- Three generic tools built from every Administrate dashboard:
admin_resource_list_resources,admin_resource_list,admin_resource_show. - Write actions declared per dashboard with
mcp_action, each published as its own tool, gated by thewritescope (an API key with write access, or an OAuth token granted it) and the resource's own authorization predicate on the loaded record. - Three ways to authenticate a caller: API keys stored as a digest, an external identity provider
through
identity_fallback(a Cloudflare Access verifier ships with the gem), and an optional built-in OAuth 2.1 server with Dynamic Client Registration, PKCE and refresh tokens, on by default and disabled with one setting. - Pundit-aware authorization by default: reads run through the same
index?andshow?predicates as the admin UI, so a resource an admin cannot see in the browser is not exposed over MCP either. - Search, filters and field selection built from the dashboard's own declarations, plus foreign key filters that need no declaration at all.
- A feedback tool,
report_mcp_improvement, so the client can tell you which of your descriptions, filters and fields let it down, and you can fix them. See Improving the server from its own use. The signal is always available throughconfig.on_feedback; storing it, the dashboard and the clean-up service are a batteries-included option a host turns on withconfig.persist_feedback. - The admin console for its own tables, as two dashboards and two controller concerns you include in controllers of your own: issuing an API key, revoking one, and reading the feedback, without giving up your base controller, your authentication or your policies.
- Optional Sidekiq introspection tools,
sidekiq_statsandsidekiq_retries, wired to a stats provider object you supply. - No reference to a constant it does not own: field serializers are keyed on class names, dashboards opt in per attribute, and every host-specific behaviour is configuration, not a subclass.
A tools/list call against the JSON-RPC endpoint, authenticated with an API key:
curl https://admin-mcp.example.com/ \
-H 'Authorization: Bearer amcp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc": "2.0", "method": "tools/list", "id": 1}'returns the generic tools built from your dashboards, plus any mcp_action you declared, among
them:
{
"result": {
"tools": [
{ "name": "admin_resource_list_resources" },
{ "name": "admin_resource_list" },
{ "name": "admin_resource_show" },
{ "name": "report_mcp_improvement" }
]
}
}A tools/call against admin_resource_list, restricted to three fields:
{
"jsonrpc": "2.0",
"method": "tools/call",
"id": 2,
"params": {
"name": "admin_resource_list",
"arguments": { "resource": "widget", "fields": ["id", "name", "status"] }
}
}returns columns, rows and pagination metadata built from the dashboard's COLLECTION_ATTRIBUTES:
{
"columns": ["url", "id", "name", "status"],
"rows": [
[
"https://admin.example.com/admin/widgets/1",
"1",
"Turbo encabulator",
"published"
],
[
"https://admin.example.com/admin/widgets/2",
"2",
"Flux capacitor",
"draft"
]
],
"meta": { "page": 1, "per_page": 10, "total_count": 2, "total_pages": 1 }
}- Ruby 3.2 or newer.
- Rails 8.1 or newer.
- Administrate 1.0.0.beta3 or newer, below 2.0 (the search implementation calls methods
Administrate::Searchtreats as internal, see Search). - PostgreSQL. The migrations create uuid primary keys defaulted with
gen_random_uuid()and store the OAuthredirect_urisandgrant_typesas array columns.
# Gemfile
gem 'administrate-mcp'Copy the migrations and run them:
bundle install
bin/rails administrate_mcp:install:migrations
bin/rails db:migrateThe tables are administrate_mcp_api_keys, administrate_mcp_feedbacks,
administrate_mcp_oauth_applications, administrate_mcp_oauth_access_grants and
administrate_mcp_oauth_access_tokens. They use uuid primary keys and a uuid column, admin_id by
default, that is indexed but carries no foreign key constraint, so the engine works with any admin
table. Set config.admin_foreign_key before running the migrations if the host's own admin table
already uses a different column name and renaming it is not an option, for example a live
credentials table.
administrate_mcp_feedbacks is only needed when config.persist_feedback is true; leave it
false, the default, and the migration ships but the table is never read from or written to.
The smallest configuration that works. Save it as config/initializers/administrate_mcp.rb; it
must run before the engine's models load, because the admin association reads admin_class_name
and admin_foreign_key:
Administrate::MCP.configure do |c|
c.admin_class_name = 'Administrator'
c.current_admin = ->(controller) { controller.send(:warden)&.authenticate(scope: :administrator) }
c.issuer = 'https://admin-mcp.example.com'
c.admin_origin = 'https://admin.example.com'
endThen draw the routes, split across the MCP origin and the admin origin (see Routes for why):
# config/routes.rb
Rails.application.routes.draw do
constraints ->(request) { request.subdomain == 'admin-mcp' } do
Administrate::MCP::Routes.draw_mcp_origin(self)
end
constraints subdomain: 'admin' do
Administrate::MCP::Routes.draw_admin_origin(self)
end
endThis draws a full OAuth 2.1 server by default (see OAuth to turn it off) and grants every
authenticated admin access to every dashboard until you set c.authorization. For the full picture,
including hooks, authorization adapters, field serializers and identity fallback, see the reference
sections below.
The full Configuration object, the settings table, the authorization adapters, and how to
register, skip or reclassify a field class: docs/configuration.md.
Why the consent screen and the JSON-RPC endpoint are drawn on separate origins, and what each route helper adds: docs/routes.md.
MCP_DESCRIPTION, MCP_BASE_SCOPE, MCP_SKIPPED_ATTRIBUTES, MCP_EXPOSED, COLLECTION_FILTERS
and mcp_action, the constants and macro that turn one dashboard into an MCP resource with its own
readable fields and writable actions: docs/dashboards.md.
How a request is authenticated (API key, then OAuth token, then identity_fallback), how to issue
and rotate API keys, and the identity_fallback recipe for an identity resolved in front of the
application: docs/authentication.md. A Cloudflare Access verifier ships
with the gem for hosts that run edge-managed OAuth in front of the application:
docs/authentication.md#identity-fallback.
The built-in OAuth 2.1 server, what turning it off with c.oauth = false changes, and when a host
should: docs/oauth.md.
Every tool here is built from your dashboards: the resource names, the field lists, the filters and
the MCP_DESCRIPTION you wrote. The client calling those tools is the one that gets misled when any
of it is wrong, and it is the only party that knows which call it was trying to make. The engine has
no way to detect this on its own: a vague description is not an error, it is a successful call that
returned the wrong thing or a query the caller gave up on.
report_mcp_improvement is how the client tells you. Its categories are deliberately not free text.
Each one names a change you make in a dashboard:
| Category | What it points at |
|---|---|
description |
MCP_DESCRIPTION is missing, vague or actively misleading |
missing_filter |
the query needed a filter the dashboard does not declare |
missing_field |
a field the caller needed is not on the show page |
missing_resource |
a dashboard is not exposed, or does not exist |
serialization |
a field came back unreadable and needs a serializer registered |
other |
anything the categories above do not cover |
A report arrives with the category, the resource it concerns and the client's own account of what it
wanted, which is most of a change request already. Wire config.on_feedback to somewhere your team
will actually read, work through what arrives, and the next caller gets a server that describes
itself better. Turning on config.persist_feedback keeps the reports in a table so you can triage a
batch at a time rather than react to each one.
This is the loop the tool exists for. It is worth running deliberately rather than waiting for complaints: point a client at the server, give it real tasks, and collect what it could not do.
The rack-attack throttles recommended for the OAuth endpoints, and the helper that registers them for you: docs/oauth.md#rate-limiting.
Including the API key and feedback consoles the engine ships, what a host overrides in them, listing the engine's own tables over the protocol, customising the consent screen, and cleaning up old feedback: docs/admin-integration.md.
bundle install
bundle exec rspec
bundle exec rubocopNeeds a reachable PostgreSQL server; the test suite creates its own database on first run. See docs/development.md for the dummy application and the Postgres environment variables.
See SECURITY.md for how to report a vulnerability.
The JSON-RPC endpoint authenticates by bearer token only. It never falls back to a session cookie,
so a browser signed into the admin UI cannot drive the protocol endpoint. API keys, OAuth access and
refresh tokens, and OAuth authorization codes are all stored as SHA-256 digests, never in plaintext.
The Cloudflare Access verifier fails closed: a blank team domain or audience makes it refuse every
request rather than admit an unverified one. A credential does not outlive the admin who holds it,
because admin_active is checked on every call, whether the credential is an API key, an OAuth
token, or an identity resolved through identity_fallback.
Bug reports and pull requests are welcome on GitHub. See CONTRIBUTING.md for the development workflow, and CODE_OF_CONDUCT.md for how we expect people to treat each other in this project's spaces.
See CHANGELOG.md for a history of releases.
MIT. See LICENSE.txt. Copyright Sorare.