module

ActionController::MCPServer

Exposes the application's annotated routes as an MCP server using the Streamable HTTP transport.

Controllers are presented as toolboxes and their routes as tools. To keep the model's context lean only these tools are listed, plus any root tools:

  • list_toolboxes lists the controllers and their descriptions
  • open_toolbox(name) returns the definitions of the controller's tools
  • call_read_only(name, arguments) and call_tool(name, arguments) run the tools

The server is stateless, there are no sessions, so requests can be served by any instance of the application. Both the 2026-07-28 protocol and the earlier versions, which start with initialize, are supported.

Controllers can also be served as their own MCP server (@[AC::MCP(endpoint: true)]), and tools can render MCP Apps cards (@[AC::MCP(ui:)]), see ActionController::MCP.

Tool calls are dispatched in-process through the application router, so filters, authentication and error handlers apply exactly as they would for a regular request.

require "action-controller/mcp"

server = ActionController::Server.new
ActionController::MCPServer.mount(server, "/mcp")
server.run

Tool descriptions are extracted from the source code comments, so they need to be generated at build time and shipped alongside the binary:

ActionController::MCPServer.write_description("mcp.yml")

the file is lazily loaded from description_path the first time it is needed. Routes can be excluded using @[AC::MCP(hide: true)]

Constants

LEGACY_VERSIONS = {"2025-11-25", "2025-06-18", "2025-03-26"}

protocol versions that start with initialize, newest first

MODERN_VERSIONS = {"2026-07-28"}

stateless protocol versions, where every request carries its version in _meta

PROTOCOL_VERSIONS = MODERN_VERSIONS + LEGACY_VERSIONS

supported protocol versions, newest first

TOOLBOX_INSTRUCTIONS = "Tools are grouped into toolboxes. Call list_toolboxes to discover what is available and\nopen_toolbox for the definitions of the tools in a toolbox. Run them with the tool named\nin their proxy field, passing the tool name and its arguments: call_read_only for tools\nthat only read data, call_tool for the rest."

Class methods

allowed_origins

browser origins permitted to connect, in addition to same origin requests. "*" permits any origin

Source
allowed_origins=(allowed_origins : Array(String))

browser origins permitted to connect, in addition to same origin requests. "*" permits any origin

Source
auth_cache_ttl

how long a successful authentication check is cached for

Source
auth_cache_ttl=(auth_cache_ttl : Time::Span)

how long a successful authentication check is cached for

Source
auth_probe

a route used to authenticate MCP requests, i.e. /api/v1/users/current.

the route is requested in-process with the forwarded headers, a successful (2xx) response indicates the request is authenticated

Source
auth_probe=(auth_probe : String | Nil)

a route used to authenticate MCP requests, i.e. /api/v1/users/current.

the route is requested in-process with the forwarded headers, a successful (2xx) response indicates the request is authenticated

Source
authenticator

authenticates MCP requests, returning true if the request is permitted.

optional, see auth_probe for a simpler alternative

Source
authenticator=(authenticator : Proc(HTTP::Request, Bool) | Nil)

authenticates MCP requests, returning true if the request is permitted.

optional, see auth_probe for a simpler alternative

Source
cache_ttl

how long clients can cache the server description and lists (ttlMs), 2026-07-28 onwards

Source
cache_ttl=(cache_ttl : Time::Span)

how long clients can cache the server description and lists (ttlMs), 2026-07-28 onwards

Source
description_path

location of the MCP description file, generated using write_description

Source
description_path=(description_path : String)

location of the MCP description file, generated using write_description

Source
excluded_response_headers

response headers left out of tool results, matched case-insensitively. An entry ending in * matches by prefix. Everything else (Link, X-Total-Count, Content-Range, Location, ETag, ...) is returned to the model

Source
excluded_response_headers=(excluded_response_headers : Array(String))

response headers left out of tool results, matched case-insensitively. An entry ending in * matches by prefix. Everything else (Link, X-Total-Count, Content-Range, Location, ETag, ...) is returned to the model

Source
forward_headers

request headers copied from the MCP request to the route being invoked, typically used for authentication

Source
forward_headers=(forward_headers : Array(String))

request headers copied from the MCP request to the route being invoked, typically used for authentication

Source
icons

the global server's icons, see icon

Source
instructions
Source
instructions=(instructions : String | Nil)

usage instructions provided to the model, nil uses toolbox_instructions and an empty string provides none. Include toolbox_instructions when describing your domain

Source
keep_alive

how often an SSE comment is sent on idle event streams, which keeps proxies from timing them out and detects clients that have disconnected

Source
keep_alive=(keep_alive : Time::Span)

how often an SSE comment is sent on idle event streams, which keeps proxies from timing them out and detects clients that have disconnected

Source
listen_timeout

how long a subscriptions/listen stream stays open, nil keeps it open until the client disconnects. The stream is then completed and clients listen again

Source
listen_timeout=(listen_timeout : Time::Span | Nil)

how long a subscriptions/listen stream stays open, nil keeps it open until the client disconnects. The stream is then completed and clients listen again

Source
max_subscriptions

the maximum number of resources a single subscriptions/listen can subscribe to

Source
max_subscriptions=(max_subscriptions : Int32)

the maximum number of resources a single subscriptions/listen can subscribe to

Source
resource_metadata

advertises the OAuth authorization server so MCP clients can obtain and refresh access tokens. The request is provided for multi-tenant deployments

Source
resource_metadata=(resource_metadata : Proc(HTTP::Request, ResourceMetadata) | Nil)

advertises the OAuth authorization server so MCP clients can obtain and refresh access tokens. The request is provided for multi-tenant deployments

Source
server_card=(server_card : Bool)

serves an MCP Server Card at <path>/server-card for each server mounted, so clients and registries can discover how to connect before connecting. Cards name the server server_name in reverse-DNS form, a name without a namespace is given the request host's (com.example.mcp/my-app)

Source
server_card?

serves an MCP Server Card at <path>/server-card for each server mounted, so clients and registries can discover how to connect before connecting. Cards name the server server_name in reverse-DNS form, a name without a namespace is given the request host's (com.example.mcp/my-app)

Source
server_card_ttl

how long clients and caches can keep a Server Card

Source
server_card_ttl=(server_card_ttl : Time::Span)

how long clients and caches can keep a Server Card

Source
server_description

what the server does, for its Server Card. Up to 100 characters, longer descriptions are truncated. Defaults to server_title or server_name

Source
server_description=(server_description : String | Nil)

what the server does, for its Server Card. Up to 100 characters, longer descriptions are truncated. Defaults to server_title or server_name

Source
server_name

reported to clients during initialization

Source
server_name=(server_name : String)

reported to clients during initialization

Source
server_title

the server's display name, reported to clients and in its Server Card

Source
server_title=(server_title : String | Nil)

the server's display name, reported to clients and in its Server Card

Source
server_version

reported to clients during initialization

Source
server_version=(server_version : String)

reported to clients during initialization

Source
tool_proxy=(tool_proxy : Bool)

groups the tools into toolboxes, run by the call_read_only and call_tool meta tools. When disabled every tool is listed

Source
tool_proxy?

groups the tools into toolboxes, run by the call_read_only and call_tool meta tools. When disabled every tool is listed

Source
toolbox_instructions

explains how to use toolboxes, empty when tool_proxy is disabled

Source
ui_base

the folder of MCP Apps cards, @[AC::MCP(ui: "bookings/card.html")] renders <ui_base>/bookings/card.html. Cards aren't served when nil

Source
ui_base=(ui_base : String | Nil)

the folder of MCP Apps cards, @[AC::MCP(ui: "bookings/card.html")] renders <ui_base>/bookings/card.html. Cards aren't served when nil

Source
ui_meta

the default _meta.ui of every card (CSP domains, border...), a card can override it with a .meta.json file next to it, i.e. bookings/card.meta.json

Source
ui_meta=(ui_meta : UIMeta | Nil)

the default _meta.ui of every card (CSP domains, border...), a card can override it with a .meta.json file next to it, i.e. bookings/card.meta.json

Source
update_debounce

updates to a resource are sent at most once per period, clients read the resource for its current state so bursts of changes are coalesced

Source
update_debounce=(update_debounce : Time::Span)

updates to a resource are sent at most once per period, clients read the resource for its current state so bursts of changes are coalesced

Source
watchers

the watchers of resource templates with @[AC::MCP(subscribe: true)]

Source
website_url

the server's homepage or documentation, reported to clients and in its Server Card

Source
website_url=(website_url : String | Nil)

the server's homepage or documentation, reported to clients and in its Server Card

Source

Instance methods

auth_enabled?

authentication is optional and enabled when any of authenticator, auth_probe or resource_metadata is configured

Source
description(composition : Composition = Composition.default) : Description

the tool descriptions, lazily loaded from description_path.

if the file doesn't exist the description is generated from the compiled routes, however it will not include the documentation comments

Source
description=(description : Description | Nil)

replaces the current description, nil will reload it on next use

Source
endpoint_paths(composition : Composition = Composition.default) : Array(String)

the path templates of the controller endpoints, @[AC::MCP(endpoint: true)]

Source
generate_description(docs : Bool = true, composition : Composition = Composition.default) : Description

generates the MCP description from the compiled routes.

docs: true extracts the source code comments using crystal docs, which requires access to the source code

Source
icon(src : String, **fields) : Nil

adds an icon for the global server, src follows the @[AC::Icon] rules and every other argument is passed through as is

ActionController::MCPServer.icon "icons/logo.svg", sizes: ["any"]
ActionController::MCPServer.icon "https://example.com/logo-dark.png", sizes: ["48x48"], theme: "dark"
Source
mount(router : Router, path : String = "/mcp", endpoints : Bool = true, composition : Composition = composition_for(router)) : Transport

mounts the MCP endpoint at the path provided.

tool calls are dispatched via the router, typically ActionController::Server. The OAuth protected resource metadata is served at /.well-known/oauth-protected-resource<path>

endpoints: true also mounts the controller endpoints, @[AC::MCP(endpoint: true)], see mount_endpoints

Source
mount_card_previewer(router : Router, path : String = "/mcp/card/preview", mcp : String | Nil = nil, composition : Composition = composition_for(router)) : CardPreviewer

mounts a browser test harness for the MCP Apps cards, intended for development. Mount the MCP server first, the previewer connects to the path it was mounted at. mcp overrides it, i.e. when a proxy serves the application under a prefix

GET <path> lists the cards and the tools that render them, each tool has a page (<path>/tools/<name>) with a form for its arguments that renders the card in a mock MCP Apps host. The page is an MCP client of the global MCP server, so tool calls, including those made by the card, use the same routes and authentication as any other client.

The tool pages accept query params, for linking and automated tests:

  • the tool arguments by name, ?id=42, the card renders once all the required arguments are provided
  • viewport_size=desktop|tablet|mobile and theme=light|dark
  • header.<Name>=<value> request headers, i.e. header.Authorization=Bearer%20...

The page's assets are only compiled in when this method is called, see https://spider-gazelle.net/mcp/ui/ for excluding it from release builds.

Source
mount_endpoints(router : Router, composition : Composition = composition_for(router)) : Array(Transport)

mounts a server for each controller annotated @[AC::MCP(endpoint: true)]

Source
mounted_path(router : Router) : String | Nil

the path of the global MCP server mounted on the router, nil if there isn't one

Source
watch(template : String, &block : Hash(String, String), HTTP::Request, Proc(Nil) -> Proc(Nil)) : Nil

watches the resources of a template for changes, so clients can subscribe to them using subscriptions/listen. The template must match a route annotated @[AC::MCP(resource: template, subscribe: true)].

A client can only subscribe to resources it can read: the route is requested before the watcher is called. The block is called once per subscription and returns a proc that stops watching, called when the client disconnects

ActionController::MCPServer.watch("app://widgets/{id}") do |variables, _request, changed|
  subscription = Widget.on_change(variables["id"]) { changed.call }
  -> { subscription.cancel }
end
Source
write_description(path : String = description_path, composition : Composition = Composition.default) : Nil

generates the description, including source code comments, and saves it to a file

Source

Nested types