MCP Server
cm-admin ships with a built-in Model Context Protocol server that exposes your admin-registered models to MCP-compatible AI clients (Claude Desktop, Cursor, Windsurf, GitHub Copilot Chat, etc.) over JSON-RPC.
The current release (v1) is intentionally minimal and read-only: every
cm-admin model gets two tools â <model>_list and <model>_show â plus a
global list_models discovery tool. Authentication is done with Bearer
Personal Access Tokens (PATs), and every tool call goes through the same
Pundit policy scopes that gate the cm-admin web UI, so MCP cannot leak rows
that the user could not otherwise see.
What you getâ
| Tool | Description |
|---|---|
list_models | Returns every MCP-enabled cm-admin model and the actions exposed for each. Useful for discovery. |
fetch_options | Fetches options for select filters given a helper method name. |
<model>_list | Paginated, sortable, filterable list of <model> records. Honors CmAdmin::<Model>PolicyIndexScope. |
<model>_show | Full record by ID, including nested-field associations and tab data. Honors CmAdmin::<Model>Policy#show?. |
Tool names are derived from Model.name.underscore (e.g. User â user_list,
ApiToken â api_token_show). Names longer than 64 characters are truncated
while preserving the action suffix.
Setupâ
1. Enable MCP globallyâ
# config/initializers/cm_admin.rb
CmAdmin.configure do |config|
# ...your existing config...
config.mcp_enabled = true
end
When mcp_enabled is true:
- A Rack endpoint is mounted at
<cm_admin_engine_mount_path>/mcp(e.g./cm_admin/mcpif you mount cm-admin at/cm_admin). - Models with
mcp_actionsexplicitly called in their cm_admin block are MCP-enabled withlist+show.
When mcp_enabled is false (default), the route is not drawn and no MCP
endpoint is exposed.
2. Configure server identityâ
The MCP server reports itself using your Rails project settings so different apps mounting cm-admin can be distinguished by clients:
# config/application.rb
config.x.project_settings.name = 'My Cool App' # â MCP server title
config.x.project_settings.slug = 'my_cool_app' # â MCP server name
Falls back to CmAdmin / cm_admin when not set.
3. Wire up Personal Access Tokens (PATs)â
The auth middleware looks up tokens against your host app's ApiToken
model. The model needs a token_type enum (with at least a pat value) and
an expires_at column.
Migrationâ
class AddTokenTypeToApiTokens < ActiveRecord::Migration[8.1]
def change
add_column :api_tokens, :token_type, :integer, default: 0, null: false
add_column :api_tokens, :expires_at, :datetime
end
end
Modelâ
class ApiToken < ApplicationRecord
belongs_to :user
enum :status, %i[live expired]
enum :token_type, %i[access refresh pat]
validates :token, presence: true
validates :token_type, presence: true
before_validation :generate_token, on: :create
private
def generate_token
self.token ||= SecureRandom.hex(32)
end
end
Current attributesâ
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :user, :api_token # ...plus your own attrs
end
The middleware also sets
CmCurrent.user_permissions(cached oncm_role.cache_key_with_version) soCmAdmin::PermissionHelperandPundit::<Model>Policywork exactly as they do for web requests.
4. (Optional) Manage PATs from the cm-admin UIâ
Register ApiToken like any other admin model. A ready-made concern is
included with cm-admin â just include it and add the model to
config.included_models:
# app/models/api_token.rb
class ApiToken < ApplicationRecord
include CmAdmin::ApiToken
# ...rest of the model...
end
# config/initializers/cm_admin.rb
config.included_models = [..., ApiToken]
Now there's an "API Tokens" sidebar entry where users can create PATs.
Usageâ
Client configâ
The exact JSON shape varies by client; the universal fields are serverUrl
(or url) and an Authorization: Bearer <pat> header.
{
"mcpServers": {
"feature_test_app": {
"serverUrl": "https://feature-test-app.commutatus.com/cm_admin/mcp",
"headers": {
"Authorization": "Bearer YOUR_PAT_HERE"
}
}
}
}
The key (
feature_test_appabove) should matchconfig.x.project_settings.slugso the connection is obvious in the client UI.
Generating a PATâ
Either through the cm-admin UI (API Tokens â Add API Token, set
token_type = pat) or in a console:
user.api_tokens.create!(token_type: :pat, status: :live, expires_at: 30.days.from_now)
Calling tools manually (debugging)â
PAT=... # your token
HOST=http://localhost:3000
# Discovery
curl -s -X POST "$HOST/cm_admin/mcp" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H "Authorization: Bearer $PAT" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# List the first 5 users
curl -s -X POST "$HOST/cm_admin/mcp" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H "Authorization: Bearer $PAT" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"user_list","arguments":{"per_page":5}}}'
# Show a single record
curl -s -X POST "$HOST/cm_admin/mcp" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H "Authorization: Bearer $PAT" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"user_show","arguments":{"id":1}}}'
Tool referenceâ
list_modelsâ
{ "type": "object", "properties": {} }
Returns:
{ "models": [
{ "name": "User", "slug": "user", "actions": ["list", "show"] }
]}
fetch_optionsâ
| Argument | Type | Default | Description |
|---|---|---|---|
helper_method | string | â | Required. The helper method name (e.g., select_options_for_currency) |
model_name | string | â | Optional. Model name (e.g., User, DemoForm) |
context | object | â | Optional. Record-like context for parent-dependent helpers |
Returns:
{
"helper_method": "select_options_for_currency",
"options": [
{ "label": "USD", "value": "usd" },
{ "label": "EUR", "value": "eur" }
]
}
<model>_listâ
| Argument | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | 1-indexed page number |
per_page | integer | 20 | Records per page (max 100) |
sort_column | string | â | Column to sort by (enum of model's sortable_columns) |
sort_direction | string | asc | asc or desc |
filters | object | {} | Filter parameters based on cm_index filters |
Records are filtered through CmAdmin::<Model>PolicyIndexScope â the same
scope used by the cm-admin index page and exports.
Response structureâ
{
"data": [...],
"pagination": {
"current_page": 1,
"per_page": 20,
"total_count": 100,
"total_pages": 5,
"has_more": true
}
}
dataâ Array of records for the current pagepaginationâ Metadata for pagination:current_pageâ Current page numberper_pageâ Records per pagetotal_countâ Total number of records matching the querytotal_pagesâ Total number of pageshas_moreâ Whether there are more pages available
Filter schemaâ
The filters parameter accepts key-value pairs based on the model's cm_index filters.
For select filters using helper_method, the filter schema includes x-helper-method and
x-model-name metadata. Use the fetch_options tool with these values to retrieve
the current options dynamically. This keeps the schema small and ensures options are
always fresh.
<model>_showâ
| Argument | Type | Default | Description |
|---|---|---|---|
id | string | â | Required. Record ID or friendly ID (e.g., "123" or "CMAD-123"). |
per_tab | integer | 50 | Max records returned per tab association (max 200) |
Returns the record's as_json, plus:
{
"...": "...record fields...",
"friendly_id": "CMAD-123",
"nested_fields": {
"<association_name>": [
{ "id": 1, "<field>": "..." }
]
},
"tabs": [
{ "name": "Profile", "action": "", "type": null },
{ "name": "Tokens", "action": "tokens", "type": "associated_index", "records": [ ... ] }
]
}
friendly_idâ the friendly ID or formatted ID (only present if the model supports friendly_id)nested_fieldsâ everynested_form_fielddeclared inside acm_sectionundercm_show, with the fields you declared in the DSL.tabsâ everytabdeclared incm_show. Each tab includes:nameâ the tab's nav labelactionâ the action name (empty string for the default tab)typeâ the layout type (associated_index,associated_show, ornull)recordsâ present only when the tab maps to an association; policy-scoped
Per-model controlâ
By default, models are not MCP-enabled. You must explicitly call mcp_actions
inside the cm_admin block to enable MCP for a model:
class Article < ApplicationRecord
cm_admin do
mcp_actions # enables list + show
# mcp_actions only: %i[list] # list only, no show
# mcp_actions except: %i[show] # everything except show
end
end
Permissionsâ
The MCP server respects the same policy classes used by the web UI:
| Action | Policy hook |
|---|---|
<model>_list | CmAdmin::<Model>PolicyIndexScope#resolve |
<model>_show | CmAdmin::<Model>PolicyIndexScope#resolve + CmAdmin::<Model>Policy#show? |
| Tab association records | CmAdmin::<AssocModel>PolicyIndexScope#resolve |
If a policy class is missing, the call falls back to Model.all (matches
cm-admin's existing authorize_with_policy behavior). If
Pundit::NotAuthorizedError is raised, MCP returns a structured Forbidden
error to the client (not a 500).
Error responsesâ
Errors are returned as MCP tool errors with a JSON-encoded body:
| Scenario | HTTP status | Body |
|---|---|---|
Missing Authorization header | 401 | { "error": "Authorization token required" } |
| Unknown / expired PAT | 401 | { "error": "Invalid or expired token" } |
ActiveRecord::RecordNotFound | 200 (tool error) | { "error": "Record not found", "message": "..." } |
Pundit::NotAuthorizedError | 200 (tool error) | { "error": "Forbidden", "message": "..." } |
Other StandardError | 200 (tool error) | { "error": "<class>", "message": "..." } and a Rails.logger.error entry |
Architecture (for the curious)â
HTTP POST /cm_admin/mcp
â
âŒ
CmAdmin::Mcp::Middleware::Auth â validates Bearer PAT, sets Current/CmCurrent
â
âŒ
MCP::Server::Transports::StreamableHTTPTransport (stateless)
â
âŒ
MCP::Server âââââââ Tools registered by CmAdmin::Mcp::ToolFactory
âââ per-model list / show
list_models tool
Source files:
lib/cm_admin/mcp/server.rbâ buildsMCP::Serverinstancelib/cm_admin/mcp/rack_app.rbâ lazy Rack compositionlib/cm_admin/mcp/middleware/auth.rbâ Bearer PAT authlib/cm_admin/mcp/authorization.rbâ Punditpolicy_scope/authorize!lib/cm_admin/mcp/tool_factory.rbâ per-model tool generationlib/cm_admin/mcp/show_serializer.rbâ nested_fields + tabs for showlib/cm_admin/mcp/tools/list_models_tool.rbâ discovery toollib/cm_admin/mcp/tools/fetch_options_tool.rbâ select filter options fetcherlib/cm_admin/mcp/model_resolver.rbâ filter schema generation and option lookup