action-controller
Spider-Gazelle Action Controller
Extending lucky_router for a Rails like DSL without the overhead. See the docs site for usage details
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, a session starts with just five tools, 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) | adds the controller's tools and prompts to the session, sends notifications/tools/list_changed and/or notifications/prompts/list_changed, and returns the tool definitions |
| close_toolbox(name) | removes them again and sends the same notifications |
| call_read_only(name, arguments) | runs a read only tool from an open toolbox (or a root tool), for clients that don't refresh their tools when notified |
| call_tool(name, arguments) | runs any tool from an open toolbox (or a root tool), the same way |
Some MCP clients (currently including Claude and ChatGPT) don't re-fetch their tools when
notified, so the opened tools never appear. 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 instead. Calls through a proxy behave exactly
like direct calls.
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(read_only:)]. Disable both proxies with
MCPServer.tool_proxy = false once your clients support list_changed.
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 POST, GET and DELETE handlers at the path provided.
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 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 sessions 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, and a session can only be used at the URL it was created at. - 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.
- 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
end
# Checks in to a booking, only the card can call this
@[AC::MCP(app_only: true)]
@[AC::Route::POST("/:id/check_in")]
def check_in(id : String) : Booking
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 asapp_onlytools. - 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:orapp_only: trueare 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.
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 |
| endpoint: true | serves the controller as its own MCP server at <base>/mcp (controllers only), see controller endpoints |
| ui: "bookings/card.html" | renders this HTML card for the tool's results, see UI cards |
| app_only: true | only cards can call the tool, it's hidden from the model |
Tools with ui: or app_only: true default to root: true.
| read_only: Bool | whether the tool only reads data (default: GET routes). Sets readOnlyHint and whether call_read_only runs it. Use false for a GET with side effects, true for a POST search |
| prompt: true | the method is an MCP prompt (methods only) |
@[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.session_timeout = 30.minutes
mcp.excluded_response_headers += ["X-Runtime"] # left out of tool results, `*` matches a prefix
# 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
2025-11-25,2025-06-18and2025-03-26. POSTreturnsapplication/json, unless the call produced notifications and the client acceptstext/event-stream. In that case the notifications are streamed ahead of the result.GETwithAccept: text/event-streamopens a stream for server notifications.DELETEends the session.Originheaders must be same-origin or listed inallowed_origins, which protects against DNS rebinding.- Sessions are stored in memory per process. Multi-node deployments need sticky sessions. Sessions are not credentials: when authentication is enabled every request must be authenticated.
- Unhandled exceptions raised by a route are logged and returned as a generic
500tool error. - Clients that support
notifications/tools/list_changedsee the tools added byopen_toolboxdirectly, others usecall_read_onlyandcall_tool. Prompts added by a toolbox neednotifications/prompts/list_changed. Root items don't require either.
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