Skip to main content

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​

ToolDescription
list_modelsReturns every MCP-enabled cm-admin model and the actions exposed for each. Useful for discovery.
fetch_optionsFetches options for select filters given a helper method name.
<model>_listPaginated, sortable, filterable list of <model> records. Honors CmAdmin::<Model>PolicyIndexScope.
<model>_showFull 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/mcp if you mount cm-admin at /cm_admin).
  • Models with mcp_actions explicitly called in their cm_admin block are MCP-enabled with list + 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 on cm_role.cache_key_with_version) so CmAdmin::PermissionHelper and Pundit::<Model>Policy work 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_app above) should match config.x.project_settings.slug so 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​

ArgumentTypeDefaultDescription
helper_methodstring—Required. The helper method name (e.g., select_options_for_currency)
model_namestring—Optional. Model name (e.g., User, DemoForm)
contextobject—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​

ArgumentTypeDefaultDescription
pageinteger11-indexed page number
per_pageinteger20Records per page (max 100)
sort_columnstring—Column to sort by (enum of model's sortable_columns)
sort_directionstringascasc or desc
filtersobject{}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 page
  • pagination — Metadata for pagination:
    • current_page — Current page number
    • per_page — Records per page
    • total_count — Total number of records matching the query
    • total_pages — Total number of pages
    • has_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​

ArgumentTypeDefaultDescription
idstring—Required. Record ID or friendly ID (e.g., "123" or "CMAD-123").
per_tabinteger50Max 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 — every nested_form_field declared inside a cm_section under cm_show, with the fields you declared in the DSL.
  • tabs — every tab declared in cm_show. Each tab includes:
    • name — the tab's nav label
    • action — the action name (empty string for the default tab)
    • type — the layout type (associated_index, associated_show, or null)
    • 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:

ActionPolicy hook
<model>_listCmAdmin::<Model>PolicyIndexScope#resolve
<model>_showCmAdmin::<Model>PolicyIndexScope#resolve + CmAdmin::<Model>Policy#show?
Tab association recordsCmAdmin::<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:

ScenarioHTTP statusBody
Missing Authorization header401{ "error": "Authorization token required" }
Unknown / expired PAT401{ "error": "Invalid or expired token" }
ActiveRecord::RecordNotFound200 (tool error){ "error": "Record not found", "message": "..." }
Pundit::NotAuthorizedError200 (tool error){ "error": "Forbidden", "message": "..." }
Other StandardError200 (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 — builds MCP::Server instance
  • lib/cm_admin/mcp/rack_app.rb — lazy Rack composition
  • lib/cm_admin/mcp/middleware/auth.rb — Bearer PAT auth
  • lib/cm_admin/mcp/authorization.rb — Pundit policy_scope / authorize!
  • lib/cm_admin/mcp/tool_factory.rb — per-model tool generation
  • lib/cm_admin/mcp/show_serializer.rb — nested_fields + tabs for show
  • lib/cm_admin/mcp/tools/list_models_tool.rb — discovery tool
  • lib/cm_admin/mcp/tools/fetch_options_tool.rb — select filter options fetcher
  • lib/cm_admin/mcp/model_resolver.rb — filter schema generation and option lookup