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. Provide domain context (system prompt)​

The MCP server sends an instructions field to the AI client during the initialize handshake. This is the system prompt the AI reads before it calls any tool, so it is the right place to give it domain-specific knowledge about your data model, business semantics, and known limitations.

Set config.mcp_system_prompt to inject your own context into every AI session:

# config/initializers/cm_admin.rb
CmAdmin.configure do |config|
config.mcp_enabled = true
config.mcp_system_prompt = <<~PROMPT
You are reading records from the Acme Platform via MCP tools.

Data model notes:
- `assignee` is the current owner of a work item, not necessarily the
original tester or creator.
- `internal_feedback_rating` is currently unused. QA ratings live inside
each work item's history and cannot be bulk-aggregated through MCP tools.
If a user asks for QA rating counts, explain the limitation rather than
returning incorrect results.

Best practices:
- Use aggregate tools for counts/sums/averages instead of listing all rows.
- Use `fetch_options` to resolve select filter values before filtering.
PROMPT
end

When mcp_system_prompt is left nil (default), the server falls back to a generic one-line instruction. Keep the prompt focused on semantic guidance and business rules — avoid pasting the full database schema, which the AI can already discover through list_models and the tool schemas.

4. 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.

5. (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