action-controller
Spider-Gazelle Action Controller
Extending lucky_router for a Rails like DSL without the overhead. See the docs site for usage details
Composable applications
Every controller class provides .handler, which returns a fresh HTTP::Handler serving that class and its concrete descendants. It also works on an abstract application base such as the template's App::Base:
HTTP::Server.new([
App::Base.handler,
OtherAC::App::Base.handler,
HTTP::StaticFileHandler.new("www", directory_listing: false),
])
A route miss calls the next handler. A matched action keeps its response, including a deliberate 404 or an authentication failure. Application filters run only when one of that application's routes matches. Use a fresh handler for each server or chain; request controllers still take an HTTP::Server::Context in their constructor.
Explicit compositions and apps with mounts isolate matched routes from path parameters set by upstream handlers. Route misses preserve the context for the next handler. Ordinary automatic routing retains its existing path parameter behavior.
For one server with unified route listing, OpenAPI and MCP, select application roots in config.cr, after requiring their controllers:
ActionController::Server.compose(App::Base, OtherAC::App::Base)
This is a compile-time declaration, so it also applies to the template's --routes, --docs and --mcp options, which execute before configuration initialization. Declare it once. Existing Server.new, Server.before, Server.after, OpenAPI and MCP calls work unchanged. Without a declaration, controllers are discovered automatically as before. Reusable apps should expose their controllers in a library entry point; require their executable startup and global configuration only when running them standalone.
Mounting controllers and apps
class MyApp < AC::Base
base "/myapp/"
mount "/auth/", OtherAC::App::OAuth2
end
The mount replaces the target's base. If OAuth2 has base "/oauth2" and a /token action, the public URL is /myapp/auth/token. Targets can use names relative to the declaring controller's namespace and can be declared later. A mounted application base includes its descendants, preserving their paths relative to that base. For example, an app with base "/api" and a descendant controller with base "/api/users" mounted at /service exposes that controller at /service/users. Controller bases outside the app base retain their whole path beneath the mount. base continues to set each controller's own path; it does not implicitly prefix descendants.
Mounts can be nested or repeated. Mount-only targets are excluded from standalone automatic discovery. Explicitly selecting an application includes its descendants independently of mounts declared in other applications; its own mount declarations determine which descendants are relocated. Mounting a whole application likewise includes its complete subtree. Mounted controllers keep their own filters and exception handlers. Parent controllers' filters apply to their own actions. Requests retain their public path, and unmatched mounted routes continue downstream.
Parameterized mounts such as mount "/accounts/:account_id/auth", OAuth2 bind those parameters for controller filters and actions, OpenAPI and MCP. Mounts must preserve any required parameters from the target's original routes as required parameters. Cycles, ambiguous parameter names and conflicting public operations are rejected. Explicit composition also checks equivalent parameterized routes and generated HEAD operations. MCP endpoint conflicts are checked against the actual router before endpoints are registered.
Optional mount segments retain the action's argument requirements. If a query argument becomes an optional path segment, OpenAPI describes its query form on URLs that omit the segment; required action and filter arguments stay required in MCP. Replacing a base that contains an optional parameter moves declared arguments back to queries. If the replacement makes that segment mandatory, tools and prompts require its value. MCP endpoint listings omit only arguments bound by the request URL, so clients connected without an optional segment can still supply that argument.
Use route_path(:action, ...) inside an action to generate a URL using the current mounted base and bound path parameters:
redirect_to route_path(:token)
Explicit URL arguments override bound path values. Helpers encode individual path segments and support optional (?:) and glob (*:) segments; later optional segments require earlier ones. Existing class URL helpers such as OAuth2.token retain their original URLs. Outside a request, use composition.url_for(OAuth2, :token, ...); supply mount_base: to choose between repeated mounts.
Independent compositions and catalogs
composition = AC::Composition.new([MyApp.name, AnotherApp::Base.name])
server = AC::Server.new(composition: composition)
AC::MCPServer.mount(server, "/mcp")
docs = AC::OpenAPI.generate_open_api_docs(
title: "Combined API", version: "1.0", composition: composition,
)
AC::MCPServer.write_description("combined-mcp.yml", composition: composition)
client = AC::SpecHelper.new(composition).hot_topic
For direct controller tests, spec_instance accepts the same composition and binds the requested mount's base and path parameters without executing actions or filters. Existing calls use the configured default composition:
instance = OtherAC::App::OAuth2.spec_instance(
HTTP::Request.new("GET", "/myapp/auth/token"), composition: composition,
)
instance.route_path(:token) # => "/myapp/auth/token"
OpenAPI uses public mounted paths and distinct operation IDs. MCP uses the same composition for tool calls, prompts, internal instructions, and relocated controller endpoints. Repeated mounts receive separate toolboxes and unique tool/prompt names. Existing visibility annotations and authentication settings still apply; the host owns global MCP configuration and UI asset locations.
For controller inheritance, the nearest controller with an @[AC::MCP(...)] annotation supplies defaults for both inherited and newly declared actions. A child controller annotation replaces those defaults; method annotations take precedence. Icons use the nearest controller that declares them; a method's icons take precedence. Children can change or disable inherited endpoints, enable an endpoint using inherited instructions, and override the instructions method.
MCP description caches are scoped to the composition and description file. Generated files include a composition identity incorporating the catalog version; a mismatched file is regenerated from compiled metadata without source comments. Regenerate descriptions with --mcp or write_description to retain those comments. Legacy description files remain supported for ordinary apps without explicit composition or mounts.
Controller selection, mount expansion, conflict validation and catalog projection run during compilation or initialization. Server registers all placements in one routing table. Exact static routes use the existing allocation-free lookup; dynamic routes traverse LuckyRouter's trie using temporary byte views, copying captures only after a successful match. Escaped paths retain its original decoder. Ordinary controller routes retain direct dispatch procs, which outperform the tested callable-object and integer dispatch alternatives with mixed targets. Relocated routes bind their public base on the request context, without allocating a placement or rewriting the request. Warmed MCP descriptions use atomic cache snapshots, without taking the catalog lock or hashing the composition. Explicit handler chains perform another lookup for every handler that misses. See the performance benchmark for repeatable comparisons and their limits.
Strong Parameter Usage
require "action-controller"
# Abstract classes don't generate routes
abstract class Application < ActionController::Base
# A filter can raise or render to prevent a route being executed
@[AC::Route::Filter(:before_action)]
def ensure_authenticated
render :unauthorized unless cookies["user"]?
end
# You can define controller level exception handlers for consistent error messages
# note, the first param is always the error object
@[AC::Route::Exception(AC::Route::Param::Error, status_code: HTTP::Status::BAD_REQUEST)]
def route_param_error(error, id : Int64?)
# as id is nillable, it will look for a supplied id (route, query, formdata)
# and set it if one was found and it could be converted
{
error: error.message,
parameter: error.parameter,
restriction: error.restriction,
}
end
end
# Full inheritance support (concrete classes generate routes)
class Books < Application
# this is automatically configured based on class name and namespace
# it can be overriden here
base "/books"
# route => "/books/?book=1234"
@[AC::Route::GET("/")]
def index(book : UInt64? = nil) : Array(String)?
# redirecting renders a response, so nothing is returned
return redirect_to Books.show(id: book) if book
["book1", "book2"]
end
# Params are automatically extracted and converted to the correct type
# here `id` in the route matches the `id` parameter in the function
# route => "/books/0FF/hex"
# route => "/books/123"
@[AC::Route::GET("/:id/hex", config: {id: {base: 16}})]
@[AC::Route::GET("/:id")]
def show(id : UInt64) : NamedTuple(id: UInt64, name: String)
{id: id, name: "book1"}
end
enum Colour
Red
Blue
Green
end
# route => "/books/set_colour/RED"
# route => "/books/set_colour/colour_value/2"
@[AC::Route::GET("/set_colour/:colour")]
@[AC::Route::GET("/set_colour/colour_value/:colour", config: {colour: {from_value: true}})]
def set_colour(colour : Colour) : String
colour.to_s
end
# a nilable enum param silently resolves to nil when the value doesn't parse.
# `strict: true` raises `AC::Route::Param::ValueError` (bad request) instead,
# while an absent param still resolves to the default
# route => "/books/tint?colour=RED"
@[AC::Route::GET("/tint", config: {colour: {strict: true}})]
def tint(colour : Colour? = nil) : String
colour.to_s
end
# Websocket support, the first param is always the socket object
# route => "/books/:id/realtime"
@[AC::Route::WebSocket("/:id/realtime")]
def realtime(socket, id : UInt64)
SOCKETS << socket
socket.on_message do |message|
SOCKETS.each { |sock| sock.send "Echo back from server: #{message}" }
end
socket.on_close do
SOCKETS.delete(socket)
end
end
SOCKETS = [] of HTTP::WebSocket
end
MCP Server
Action Controller can expose your annotated routes to LLM clients as a Model Context Protocol server using the Streamable HTTP transport.
Controllers are presented as toolboxes, their route methods as tools, and methods
annotated with @[AC::MCP(prompt: true)] as prompts. To keep the model's context
lean, only four tools are listed, plus any root items:
| tool | description |
|------|-------------|
| list_toolboxes | lists the controllers, their descriptions (the class comment), and their tool and prompt counts |
| open_toolbox(name) | returns the definitions of the controller's tools and the names of its prompts |
| call_read_only(name, arguments) | runs a read only tool from a toolbox |
| call_tool(name, arguments) | runs any tool from a toolbox. Both proxies refuse visibility: :card tools |
The server is stateless: opening a toolbox changes nothing, so the tool list is the same
for every request and any instance of your application can serve any request.
open_toolbox returns each tool's name, description, inputSchema, annotations and
proxy (the proxy tool to run it with), and the model runs them through the proxies.
Calls through a proxy behave exactly like direct calls, and clients can also call any
tool directly. Every prompt is listed, as users pick prompts they don't use the model's
context. Card only tools (visibility: :card) are listed too, so cards can call them.
There are two proxies so clients can tell reads from writes: call_read_only is hinted
readOnlyHint: true (so ChatGPT, for example, doesn't ask the user to confirm each call)
and refuses tools that aren't read only. A tool is read only if it's a GET route,
unless overridden with @[AC::MCP(behaviour:)]. MCPServer.tool_proxy = false removes
the toolboxes, listing every tool instead.
Tool calls and prompts are dispatched in-process through the application router, so
filters, authentication, controller exception handlers and responders behave exactly
as they would for a regular HTTP request. (Server.before handlers are not run.)
Setup
The MCP server is an optional feature:
require "action-controller/server"
require "action-controller/mcp"
server = ActionController::Server.new
ActionController::MCPServer.mount(server, "/mcp")
server.run
mount registers a POST handler at the path provided (GET and DELETE respond
405), the OAuth
protected resource metadata at /.well-known/oauth-protected-resource<path>, and any
controller endpoints (endpoints: false skips them).
Tool descriptions (mcp.yml)
Like the OpenAPI generator, descriptions come from the comments in your source code. Binaries don't ship with source, so you generate a description file at build time and deploy it with the binary. It is lazily loaded the first time an MCP client connects.
# e.g. behind a `--mcp` command line switch in your app
if ARGV.includes?("--mcp")
ActionController::MCPServer.write_description("mcp.yml")
exit 0
end
Run it from the project root (it needs crystal and the source, like the OpenAPI
generator), then ship mcp.yml next to the binary:
crystal build src/app.cr -o bin/app
./bin/app --mcp
If the file is missing, the description is generated from the compiled routes without the documentation comments, and a warning is logged.
The file contains, for each controller:
- the toolbox name (snake case controller name) and description (class comment).
The module namespace shared by every controller is omitted, so
PlaceOS::Api::ZonesandPlaceOS::Api::Groups::Usersbecomezonesandgroups_users - a tool per controller method. A method with several route annotations is a single
tool, using its first
GETroute (otherwise its first route)- the tool name,
<toolbox>_<method> - the description, from the method comment
- an input schema built from the route params,
@[AC::Param::Info]descriptions and examples, and the request body
- the tool name,
- a prompt per prompt method, named
<toolbox>_<method>, with the method comment as the description and the arguments taken from the method params
Each tool's input schema is a self-contained JSON schema. Path, query and header
params are top-level properties, and the request body is the body property.
# Manages the widgets in your account
class Widgets < AC::Base
base "/widgets"
# returns the widget requested
@[AC::Route::GET("/:id")]
def show(
id : Int64,
@[AC::Param::Info(description: "include usage stats", example: "true")]
detailed : Bool = false,
) : Widget
Widget.find(id, detailed)
end
end
Here the toolbox widgets contains the tool widgets_show, which takes the
arguments {"id": 1, "detailed": true}.
WebSocket and OPTIONS routes, and routes without annotations, are not exposed.
Prompts
Prompts are
reusable message templates that users select in their MCP client. Mark a controller
method with @[AC::MCP(prompt: true)]. It must return a String, which becomes a single
user message, or an Array(AC::PromptMessage) for a conversation:
class Widgets < AC::Base
base "/widgets"
# summarise a widget for the user
@[AC::MCP(prompt: true)]
def summarise(
id : Int64,
@[AC::Param::Info(description: "the tone of the summary")]
tone : String = "casual",
) : String
"Summarise this widget in a #{tone} tone: #{Widget.find(id).to_json}"
end
# starts a widget review
@[AC::MCP(prompt: true)]
def review(id : Int64) : Array(AC::PromptMessage)
[
AC::PromptMessage.user("Review widget #{id}"),
AC::PromptMessage.assistant("Which aspects should I focus on?"),
]
end
end
- Prompts are not HTTP routes. They are only available to MCP clients and don't appear in the OpenAPI docs or route list.
- Arguments are parsed exactly like route params (converters,
config:, defaults and@[AC::Param::Info]). Non-nilable arguments without a default are required. - The controller's filters and exception handlers apply, so
before_actionauthentication and model loading work as they do for routes. A filter that responds with401triggers re-authentication when authentication is enabled. Other errors are returned to the client as JSON-RPC errors. - Prompts belong to their controller's toolbox and are listed once it's opened, unless they are root prompts.
Root tools and prompts
Items marked root: true are always available, without opening a toolbox. Use this for
the handful of tools or prompts that most conversations need:
class Widgets < AC::Base
# always listed and callable
@[AC::MCP(root: true)]
@[AC::Route::GET("/colours")]
def colours : Array(String)
["red", "green"]
end
# always listed
@[AC::MCP(prompt: true, root: true)]
def getting_started : String
"Explain how to use the widget tools"
end
end
# every route and prompt in the controller is a root item
@[AC::MCP(root: true)]
class Status < AC::Base
end
Root items keep their <toolbox>_<method> names. Toolboxes that only contain root items
are not listed by list_toolboxes.
Controller endpoints
A controller can also be served as its own MCP server, for example one per account or
room. Annotate the class with @[AC::MCP(endpoint: true)] and it's served at
<base>/mcp (or a sub path, endpoint: "/assistant"):
# Controls a room, look up its state before changing it
@[AC::MCP(endpoint: true)]
class Room < AC::Base
base "/rooms/:room_id"
# the room state
@[AC::Route::GET("/state")]
def state(room_id : String) : State
State.for(room_id)
end
# turns the lights on or off
@[AC::Route::POST("/lights")]
def lights(room_id : String, on : Bool) : Nil
Lights.set(room_id, on)
end
end
Connecting to /rooms/boardroom/mcp lists state and lights directly:
-
Bound path params: the base path params (
room_id) come from the endpoint URL. They're left out of the tool arguments, every request is bound to the URL it was sent to. -
No toolboxes: there are no meta tools or proxies, every tool and prompt is listed, named by its method.
-
Instructions: the controller's doc comment is given to the model as the server's instructions, and the server is named after the controller. Define an
instructionsmethod to build them for each client instead:# runs like a route when a client connects: filters run and path params are available def instructions(room_id : String) : String "You control the #{Room.find!(room_id).name}, look up its state before changing it." endIt isn't an HTTP route or a tool. If it fails, for example the filters respond 403 or the room doesn't exist, the client can't connect:
initializereturns the error (a 401 challenges the client to sign in again). -
Visibility: the controller is hidden from the global server unless it's also annotated
hide: false. A method annotatedhide: trueis hidden from both. -
Shared configuration: authentication, forwarded headers and tool results use the
MCPServerconfiguration, and tool calls run the controller's filters, so check access to the room (or account) in abefore_action. The protected resource metadata is served for each endpoint URL.
MCPServer.mount mounts the endpoints too (endpoints: false skips them), and
write_description includes them in mcp.yml.
UI cards (MCP Apps)
Tools can render an interactive HTML card in the conversation, using the MCP Apps extension supported by Claude, ChatGPT, VS Code and Microsoft 365 Copilot. Cards are static HTML files in a folder:
ActionController::MCPServer.ui_base = "./cards"
# the default card settings, a card can override them with a `<card>.meta.json` file
ActionController::MCPServer.ui_meta = ActionController::MCPServer::UIMeta.new(prefers_border: true)
class Bookings < AC::Base
base "/bookings"
# Shows a booking
@[AC::MCP(ui: "bookings/card.html")] # ./cards/bookings/card.html
@[AC::Route::GET("/:id")]
def show(id : String) : Booking
Booking.find!(id)
end
# Checks in to a booking, only the card can call this
@[AC::MCP(visibility: :card)]
@[AC::Route::POST("/:id/check_in")]
def check_in(id : String) : Booking
Booking.find!(id).check_in!
end
end
- How it works: the tool's
_meta.ui.resourceUripoints at the card (ui://bookings/card.html). The host reads it withresources/readand renders it in a sandboxed iframe. It sends the card the tool's arguments and its result, the{status, headers, body}envelope asstructuredContent. - Cards are self-contained HTML5 documents. By default hosts allow inline scripts and
styles, but no external resources or network requests. Declare any domains a card needs
in
ui_meta, or in a sidecar such asbookings/card.meta.json:{"csp": {"resourceDomains": ["https://cdn.example.com"]}, "prefersBorder": false}. - Talking to the host: cards use JSON-RPC over
postMessage(ui/initialize, thenui/notifications/tool-result), with or without the@modelcontextprotocol/ext-appsSDK. They can call tools too, such asvisibility: :cardtools. - Caching: hosts cache cards by URI, so tools advertise
ui://bookings/card.html?v=<content hash>. A changed card gets a new URI. - Root by default: hosts only render cards for, and let cards call, the tools in
their tool list. So tools with
ui:orvisibility: :cardare root items, listed without opening their toolbox, unless annotatedroot: false. ui:paths are relative toui_baseand must be.htmlfiles (checked at compile time). Nothing outsideui_baseis served. Hosts without MCP Apps support ignore the card metadata and show the text result.
Previewing cards
The card previewer is a test harness that renders cards in your browser, as a host would. Mount it in development:
server.run do
{% unless flag?(:release) %}
server.mount_mcp_card_previewer("/mcp/card/preview")
{% end %}
end
/mcp/card/preview lists the cards and the tools that render them. A tool's page has a
form for its arguments and renders the card once the required arguments are filled in,
straight away when they're all optional. Switch between desktop, tablet and mobile
viewports and light and dark themes, and inspect the messages between the card and the
host, the tool result and anything the card sends to the model.
The page is an MCP client of the global MCP server, at the path given to
MCPServer.mount (mount it first, or pass mcp: "/prefix/mcp"), using protocol version
2026-07-28. It discovers the server, calls the tool, reads
the card and runs it in a sandboxed iframe with the card's CSP. Tools the card calls go
through the same client, with the rules hosts apply: only listed tools, not
visibility: :model tools. Add request headers, such as Authorization, in the page;
they're kept for the browser tab. Cookies are sent automatically.
Everything can be set in the URL, for links and automated tests:
/mcp/card/preview/tools/bookings_show?id=42&viewport_size=mobile&theme=dark&header.Authorization=Bearer%20token
- tool arguments by name, objects and arrays as JSON
viewport_size:desktop(1280px),tablet(768px) ormobile(375px)theme:lightordarkheader.<Name>: a request header. Tokens in URLs end up in logs and browser history, so use test credentials
Tests can wait for #stage[data-state="ready"] (or "error"), then inspect the card in
the #card iframe. window.cardPreviewer exposes the state, the tool result and the
message log.
The previewer's pages and assets are compiled into your application only when
mount_mcp_card_previewer is called, so the flag?(:release) check above leaves them
out of release builds. The index is served without authentication, so don't mount it in
production: it lists your card tools and their descriptions.
Resources
A GET route can also be read as an MCP resource by annotating it with a URI template.
The template's variables are the route's path params and, in a trailing {?name,other},
its query params:
class Widgets < AC::Base
base "/widgets"
# the widget's current state
@[AC::MCP(resource: "app://widgets/{id}/state", mime_type: "application/json", cache_ttl: 0)]
@[AC::Route::GET("/:id/state")]
def state(id : Int64) : Widget::State
Widget.find!(id).state
end
end
resources/templates/list lists the templates and resources/read runs the matching
route in-process, like a tool call, so its filters and authentication apply. Its
response becomes the resource contents, as text, or base64 for binary types. A 404 is
reported as an unknown resource and other errors as JSON-RPC errors. cache_ttl:
(seconds) is the ttlMs clients may cache a read for, it defaults to
MCPServer.cache_ttl. The route remains a tool too.
Subscriptions
With subscribe: true, clients can subscribe to resources of the template using
subscriptions/listen (2026-07-28 onwards), and are sent
notifications/resources/updated when they change. They then read the resource for its
current state. Register a watcher for the template, it's called once per subscription
and returns a proc that stops watching:
@[AC::MCP(resource: "app://widgets/{id}/state", mime_type: "application/json", subscribe: true)]
ActionController::MCPServer.watch("app://widgets/{id}/state") do |variables, _request, changed|
subscription = Widget.on_change(variables["id"]) { changed.call }
-> { subscription.cancel }
end
A resource is only watched if the client can read it: the route is requested with the
client's credentials first. The listen stream is held open, with an SSE comment every
MCPServer.keep_alive, until the client disconnects (the watchers are then stopped),
MCPServer.listen_timeout expires (nil, the default, never does) or
Transport#close_subscriptions is called, i.e. on shutdown. Bursts of changes are
coalesced into one update per MCPServer.update_debounce. Your watchers see the changes
made on the instance holding the stream, so use a shared channel (i.e. redis pub/sub)
when running several instances.
Long running routes
Routes that take a while, such as capturing output for a period of time, can be
annotated long_running: true. Their response is written to a temporary file rather
than memory, limited to max_bytes:, and streamed to the client as the result. Clients
that accept an event stream are kept informed while the route runs: the route can report
progress, there's an SSE comment every MCPServer.keep_alive, and a client that
disconnects cancels the request.
# captures the widget's output
@[AC::MCP(resource: "app://widgets/{id}/output{?seconds}", mime_type: "text/plain", long_running: true, max_bytes: 1_048_576)]
@[AC::Route::GET("/:id/output")]
def output(id : Int64, seconds : Int32 = 30) : Nil
response.content_type = "text/plain"
@__render_called__ = true
seconds.times do |second|
break if mcp_context.try { |context| context.cancelled? || context.full? }
response << Widget.find!(id).read_output
response.flush
mcp_context.try &.progress(second + 1, seconds)
sleep 1.second
end
end
mcp_context is nil for regular HTTP requests. Progress is sent when the client
provided a progressToken. The result isn't sent until the route returns, so clients
that time out requests without progress may give up on long captures.
Connecting a client
Point any MCP client that supports Streamable HTTP at the mounted path, for example with Claude Code:
claude mcp add --transport http my-app http://localhost:3000/mcp \
--header "Authorization: Bearer <token>"
Headers in forward_headers (by default Authorization, Cookie and X-API-Key) are
copied onto every tool call, so your existing authentication filters apply.
Authentication (optional)
By default the MCP endpoint itself is open. Anyone can connect and browse the
toolboxes, and each tool call is authenticated by your routes using the forwarded
headers. A route that responds with 401 is reported to the model as a tool error.
This works well for static credentials such as API keys, but MCP clients only sign
in, or refresh an expired token, when the MCP endpoint itself responds with
HTTP 401 and a WWW-Authenticate challenge. Enable authentication to get that
behaviour. It is enabled when any of the following are configured.
Validating credentials: choose one of these.
# a route that requires authentication, requested in-process with the forwarded headers.
# A 2xx response means the request is authenticated, no shared code required
ActionController::MCPServer.auth_probe = "/api/users/current"
# or a custom check
ActionController::MCPServer.authenticator = ->(request : HTTP::Request) do
MyAuth.valid_token?(request.headers["Authorization"]?)
end
Advertising your OAuth authorization server: this lets clients sign in
interactively and refresh tokens automatically. It uses
RFC 9728 protected resource metadata,
served at /.well-known/oauth-protected-resource/mcp (/mcp being the mount
path). The block receives the request, so multi-tenant applications can respond
per host.
ActionController::MCPServer.resource_metadata = ->(request : HTTP::Request) do
ActionController::MCPServer::ResourceMetadata.new(
authorization_servers: ["https://#{request.hostname}"],
scopes_supported: ["public"],
)
end
The authorization server must publish
RFC 8414 metadata and support the
authorization code flow with PKCE. MCP clients also need a way to obtain a client
id: dynamic client registration, client ID metadata documents, or a pre-registered
client. Without authenticator or auth_probe, requests only need to present
credentials, and invalid ones are rejected when a tool is called.
When authentication is enabled:
- Every
POST,GETandDELETEto the MCP endpoint is checked, includinginitialize. A failure returns401withWWW-Authenticate: Bearer resource_metadata="…", scope="…", error="invalid_token". Theresource_metadataandscopeparameters are included whenresource_metadatais configured. - Successful checks are cached per credential for
auth_cache_ttl(default 1 minute), so the probe isn't run for every message. - If a tool call is rejected by a route with
401, for example because the token expired mid-session, the MCP endpoint returns HTTP401instead of a tool error. The client then refreshes its token and retries. A403is still reported to the model as a tool error.
Choosing an approach:
| Scenario | Configuration |
|----------|---------------|
| Public or anonymous API | nothing |
| Agents with long lived credentials | nothing, or auth_probe to reject bad keys at connect time. Clients send a header, e.g. --header "X-API-Key: …" |
| Interactive users with OAuth | auth_probe (or authenticator) + resource_metadata |
For example, a multi-tenant API whose tokens are issued by an OAuth server on the same host:
ActionController::MCPServer.tap do |mcp|
mcp.auth_probe = "/api/engine/v2/users/current"
mcp.resource_metadata = ->(request : HTTP::Request) do
ActionController::MCPServer::ResourceMetadata.new(
authorization_servers: ["https://#{request.hostname}"],
scopes_supported: ["public"],
)
end
end
Annotation options and hiding routes
@[AC::MCP] options can be applied to a controller or a method, and the method level
annotation takes precedence:
| option | description |
|--------|-------------|
| hide: true | excludes the routes / prompts from MCP |
| root: true | always available, without opening the toolbox |
| prompt: true | the method is an MCP prompt (methods only) |
| title: "Book a room" | the tool or prompt's display name (methods only) |
| behaviour: :read_only | what the tool does, see below |
| visibility: :card | who can call the tool: :model, :card or both (the default) |
| ui: "bookings/card.html" | renders this HTML card for the tool's results, see UI cards |
| endpoint: true | serves the controller as its own MCP server at <base>/mcp (controllers only), see controller endpoints |
Tools with ui: or visibility: :card default to root: true.
Behaviour tells hosts what a tool does, which they use to decide what to confirm with
the user. It's a symbol or an array of :read_only, :additive, :destructive,
:idempotent, :open_world and :closed_world. By default it's inferred from the HTTP
verb: GET is :read_only, PUT is :idempotent and DELETE is
[:destructive, :idempotent]. Setting it replaces the default, and contradictions are
compile errors. Read only tools can be run with the call_read_only proxy.
@[AC::MCP(behaviour: :read_only)] # a POST search
@[AC::MCP(behaviour: [:additive, :open_world])] # a POST that sends an email
@[AC::MCP(behaviour: :destructive)] # a PUT that replaces data
Visibility controls who can call a tool in hosts that support
UI cards: :card tools are hidden from the model, and :model
tools can't be called by cards. Hosts enforce it, and the call_tool and
call_read_only proxies refuse :card tools, but it isn't access control: a client can
still call a tool by name.
Upgrading: read_only: was replaced by behaviour: (read_only: true is
behaviour: :read_only), and card_only: by visibility: :card. The old names are
compile errors. Regenerate mcp.yml after upgrading.
Icons are added with @[AC::Icon], which you can repeat for different sizes and
themes. On a controller they're the default for its tools and prompts, and the icon of
its toolbox and endpoint:
@[AC::Icon(src: "icons/book.svg", sizes: ["any"])]
@[AC::Icon(src: "icons/book-dark.svg", sizes: ["any"], theme: "dark")]
@[AC::Route::POST("/")]
def create(booking : Booking) : Booking
src is sent as is when it's an https: or data: URL. A file in MCPServer.ui_base
is sent as a data: URL, and anything else is a path on the current host. Every other
argument is passed through as is. Use MCPServer.icon "icons/logo.svg", sizes: ["any"]
for the server's own icon.
@[AC::MCP(hide: true)] # hide every route in the controller
class Internal < AC::Base
@[AC::MCP(hide: false)] # method annotations take precedence
@[AC::Route::GET("/status")]
def status : String
"ok"
end
end
Configuration
ActionController::MCPServer.tap do |mcp|
mcp.description_path = "mcp.yml" # relative to the working directory
mcp.server_name = "my-app"
mcp.server_version = "1.2.0"
mcp.instructions = "About my-app. #{mcp.toolbox_instructions}" # nil (default): toolbox usage only, "": none
mcp.tool_proxy = true # the call_read_only and call_tool meta tools
mcp.forward_headers = ["Authorization", "Cookie", "X-API-Key"] # copied onto tool requests
mcp.allowed_origins = ["https://app.example.com"] # "*" allows any origin
mcp.cache_ttl = 5.minutes # how long 2026-07-28 clients cache lists (ttlMs)
mcp.excluded_response_headers += ["X-Runtime"] # left out of tool results, `*` matches a prefix
mcp.icon "icons/logo.svg", sizes: ["any"] # the server's icon, see Icons above
# UI cards (MCP Apps), see above
mcp.ui_base = "./cards"
mcp.ui_meta = ActionController::MCPServer::UIMeta.new(prefers_border: true)
# authentication, all optional (see above)
mcp.auth_probe = "/api/users/current"
mcp.authenticator = ->(request : HTTP::Request) { true }
mcp.auth_cache_ttl = 1.minute
mcp.resource_metadata = ->(request : HTTP::Request) do
ActionController::MCPServer::ResourceMetadata.new(authorization_servers: ["https://auth.example.com"])
end
end
Transport notes
- Implements Streamable HTTP for protocol versions
2026-07-28,2025-11-25,2025-06-18and2025-03-26. - Stateless: there are no sessions, so multi-node deployments don't need sticky sessions or shared storage. When authentication is enabled every request is authenticated.
2026-07-28clients don't initialize: each request carries its protocol version and client capabilities in_meta, and theMCP-Protocol-Version,Mcp-MethodandMcp-Nameheaders must match the body (400otherwise).server/discoverdescribes the server, results includeresultType, the server info in_meta, and liststtlMsandcacheScope(privatewhen authentication is enabled).subscriptions/listenis acknowledged with nothing to notify, as the lists never change.- Earlier versions start with
initialize, which returns noMcp-Session-Id. Any session id a client sends is ignored, and clients without anMCP-Protocol-Versionheader are treated as2025-03-26. POSTreturnsapplication/json.GETandDELETErespond405, there are no event streams or sessions to end.Originheaders must be same-origin or listed inallowed_origins, which protects against DNS rebinding.- Unhandled exceptions raised by a route are logged and returned as a generic
500tool error.
Tool results
A tool call returns the route's response as {status, headers, body}, both as the text
content and as structuredContent:
{
"status": 200,
"headers": {"X-Total-Count": "120", "Link": "</api/users?page=2>; rel=\"next\""},
"body": [{"id": 1, "name": "Steve"}]
}
bodyis the parsed JSON, or the text of any other response. It's left out when empty, and so isheaders.- Headers matching
excluded_response_headersare left out. By default these areDate,Content-Length,X-Request-ID,Content-Type, cookies and credentials, transport headers (Connection,Transfer-Encoding...) and browser policy headers (Strict-Transport-Security,Access-Control-*...). Everything else, such asLink,X-Total-Count,Content-RangeandLocation, is returned so the model can page through results. - Responses with a status of 400 or above set
isError(except401when authentication is enabled, see above). - Images and audio are returned as an
imageoraudiocontent block, followed by the envelope without a body.
More information
- For more details on usage, see the documentation.
- Also see detailed project documentation
- Running heavy endpoints on dedicated scheduler pools: Execution Contexts