Admin MCP

The operator-facing MCP endpoint exposes the resource DSL through four read tools. It is separate from any customer-facing product MCP. These tools were introduced in 0.6.0 and first published in 0.6.1. Host applications must explicitly configure authentication and authorization before using them.

Configure

Mount the engine normally. The endpoint is POST <mount>/mcp; for example /internal/admin/mcp. It uses the official mcp SDK’s stateless HTTP transport. GET streams and session deletion are unsupported (405); notifications return 202 with no body. Browser Origin must equal the request base URL; headless clients can omit it. Keep the host’s Rails HostAuthorization configuration in place.

AdminSuite.configure do |config|
  config.auth_strategy = :host_user
  config.auth_options = { resolve: ->(controller) { controller.current_user } }
  config.authorize = ->(actor:, action:, resource:, record:, context:) do
    actor&.admin? && (context.web? || action == :read)
  end
  config.mcp.enabled = true
  config.mcp.max_page_size = 100
end

Adapt admin? and user resolution to the host’s actual policy. Existing SSO and HTTP Basic strategies continue to work; MCP adds no credentials or token model. Hosts whose SSO redirects unauthenticated clients need an authenticated session for MCP too.

MCP returns nothing until config.authorize is set. A nil hook advertises no tools and rejects calls. A nil actor is rejected before dispatch even when the non-production allow_unauthenticated escape hatch is enabled. Existing authentication denials retain the strategy’s response (403/challenge/redirect); a request that passes authentication without resolving an actor gets 401.

The hook receives context.surface == :mcp, context.request, and action: :read. Resource access uses record: nil; get_record additionally authorizes the returned record. Collection policies must authorize the full resource data set; this release does not provide tenant/row scoping for list/aggregate. Deny collection reads or set mcp false when the actor must not read the full declared data set.

Disable the endpoint globally with config.mcp.enabled = false (404), or exclude a resource:

class Admin::Resources::PrivateResource < Admin::Base::Resource
  model PrivateRecord
  mcp false
end

Tools

Resource names are singular DSL names (invoice, not invoices), as returned by discovery.

Tool Arguments Result
describe_resources none Accessible resources with portal, section, declared index fields, filters, search/sort keys, default page sizes, and declared actions as metadata (not callable)
list_records resource required; q, filters, sort, direction, page, per_page optional resource, page, applied_per_page, rows with declared index columns
get_record resource, id strings Declared show fields; declared association panels under associations
aggregate resource required; q, filters optional resource, filtered count, declared stats; no raw records

filters maps declared filter names to values. Search/sort/filter behavior shares AdminSuite::Query with the index. Search follows the existing three-character minimum. Requested and default page sizes are capped, including invalid/missing per_page values. The default cap is 100; use a positive integer for max_page_size.

Association panels require explicit columns:; MCP never infers all model attributes. Each panel returns applied_limit and rows, limited by the panel’s limit/per_page and the MCP cap. Panels without declared columns and custom HTML renderer bodies are omitted. The response is a bounded snapshot, not a complete association export.

Unknown, disabled and denied resources return the same tool error. Missing and denied record IDs return the same error. Raising queries/serializers return generic tool errors without stack traces. Raising individual fields become null; raising stats become null.

Client smoke test

Use an existing authenticated session or the configured authentication strategy. Send Content-Type: application/json and Accept: application/json, text/event-stream.

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"admin-smoke","version":"1.0"}}}

Send notifications/initialized without an id, then tools/list, then:

{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_records","arguments":{"resource":"invoice","per_page":25}}}

Use the negotiated MCP-Protocol-Version on subsequent requests. Compare the returned IDs/count with the same query in the admin index. Verify missing auth, denied policy, disabled resource, foreign Origin and excessive page-size requests before rollout.

Instrumentation

Subscribe once at boot (not on every code reload):

ActiveSupport::Notifications.subscribe("admin_suite.mcp.tool_call") do |*args|
  event = ActiveSupport::Notifications::Event.new(*args)
  Rails.logger.info(event.payload.slice(:tool, :resource, :action,
    :actor_type, :actor_id, :request_id, :filters, :q, :allowed, :error,
    :result_count, :duration_ms).to_json)
end

The host owns persistence into its request log. actor_type/actor_id identify model-backed operators without treating developer IDs as customer user IDs. request_id correlates the HTTP request; error distinguishes failed execution from a successful authorized read. The gem creates no tables or migrations. SDK-level rejections before tool dispatch do not emit a tool-call event. No LLM provider, chat UI, write tool or MCP Apps template ships in 0.6.0.

Transport reference: MCP Streamable HTTP.