Agent reference#
A dense, self-contained summary of Spider-Gazelle (action-controller ~> 9.3) for AI coding
agents and experienced developers. Paste it into an agent's context, or point the agent at
/llms.txt (an index of the site) or
/llms-full.txt (the whole site as Markdown).
Project conventions (template)#
src/app.cr entry point: CLI flags (-b -p -w -r -c --docs --file --mcp), server start
src/config.cr requires controllers, server, MCP; logging, handlers, sessions, MCP config
src/constants.cr NAME, VERSION, env vars (SG_ENV, SG_SERVER_PORT, SG_MCP_PATH ...)
src/controllers/ application.cr (abstract base) + one class per resource
spec/ spec_helper.cr requires src/config.cr; AC::SpecHelper.client
- Controllers inherit from an abstract
Application < AC::Base(the template calls itApp::Base). Shared filters, responders and error handlers go there. base "/path"sets the controller's path. It defaults to the snake case class name.- Build with
shards build, test withcrystal spec, and format withcrystal tool format.
Routes#
# Doc comment: first line is the OpenAPI summary; the whole comment is the MCP tool description
@[AC::Route::GET("/:id")] # GET POST PUT PATCH DELETE OPTIONS WebSocket
def show(id : Int64) : Model # args are params; the return type is the response schema
@[AC::Route::POST("/", body: :model, status_code: HTTP::Status::CREATED)]
def create(model : Model) : Model # body: names the argument parsed from the request body
@[AC::Route::GET("/", status: {A => HTTP::Status::OK, B => HTTP::Status::ACCEPTED})]
def index : A | B # status codes mapped by return type
@[AC::Route::GET("/raw", content_type: "text/plain")] # force the response type
@[AC::Route::GET("/:a", map: {value: :a})] # argument `value` reads param `a`
@[AC::Route::GET("/:id", config: {id: {base: 16}})] # converter options
@[AC::Route::GET("/:id", converters: {id: MyConverter})]
- Path segments:
:name(required),?:name(optional) and*:name(glob). - Params are taken from the path, then the query, then form data. Bodies use parsers
chosen by
Content-Type; responses use responders chosen byAccept. - A parameter is required unless it's nilable or has a default.
- Built-in conversions: numbers,
String,Char,Bool,Time,Enum,UUID, and unions.Boolistrueonly fortrue(any case); any other value isfalse. Arrays need a custom converter. @[AC::Param::Info(description:, example:, name:, header:, class:, config:)]sits on an argument. There's norequired:.- Multiple route annotations on one method are allowed. MCP exposes them as one tool using the first GET route.
Filters and errors#
@[AC::Route::Filter(:before_action, except: [:index])] # :before_action :around_action :after_action
def authenticate(@[AC::Param::Info(header: "Authorization")] auth : String) ...
@[AC::Route::Filter(:around_action)]
def transaction(&) Database.transaction { yield } end # around filters must yield
skip_action :authenticate, only: :show
@[AC::Route::Exception(NotFound, status_code: HTTP::Status::NOT_FOUND)]
def not_found(error) : ErrorBody # handlers are inherited
- Missing or invalid params raise
AC::Route::Param::MissingError/ValueError. Without an exception handler they become a500; the template maps them to422and400. -
rescue_from Klass, :methodalso works, but its handler mustrenderitself and it adds nothing to OpenAPI. Prefer the annotation. -
renderandheadshort-circuit an action or filter, e.g.head :unauthorized. - Filters can take typed params, which also appear in OpenAPI and MCP.
force_tls(aliasforce_ssl) redirects plain HTTP to HTTPS. Withoutonly:/except:it covers every route. Proxy headers decide the protocol, otherwise a connection to a portServerbound with TLS is HTTPS.
Sessions and cookies#
session["key"] = valuestoresString,Int64,Float64orBoolin an encrypted cookie; reads return that union, so use.as(Int64?)etc.cookiesholds the cookies the client sent. Set or delete cookies onresponse.cookies.
Responders and parsers#
abstract class Application < AC::Base
add_responder("application/yaml") { |io, result| result.to_yaml(io) }
default_responder "application/json"
add_parser("application/yaml") { |klass, body_io| klass.from_yaml(body_io.gets_to_end) }
end
OpenAPI#
ActionController::OpenAPI.generate_open_api_docs(title:, version:, **info)returns a NamedTuple; call.to_yamlon it. It's OpenAPI 3.1 (JSON Schema 2020-12 schemas) by default; passopenapi: "3.0.3"for 3.0 tooling.- It needs the source code and
crystal docs(comments are extracted at generation time), so generate at build time:./app --docs --file=openapi.yml. - Only annotated routes are documented, not the DSL
get "/" do.OPTIONSroutes aren't documented either. - Every
JSON::Serializabletype and enum is one component,$ref'd wherever it's used (nested too). Nilable refs areanyOfwith{type: "null"}(3.0.3:allOf+type+nullable), and self-referencing types work. Component names come from the full type name:Shop::Item→Shop.Item,Page(Shop::Item)→Page-oShop.Item-c. - Optional (
?:) segments match where they're written, one after another, and a glob (*:) may be left off; the document lists a path per matched route (<operationId>_without_<x>for the shorter ones).Param::Infoexamples are written as in a URL and typed by the schema; array query params are comma separated (explode: false).
MCP#
require "action-controller/mcp"
ActionController::MCPServer.mount(server, "/mcp") # in app.cr
ActionController::MCPServer.write_description("mcp.yml") # at build time (needs source)
@[AC::MCP(hide: true)] # exclude a route/controller
@[AC::MCP(root: true)] # always listed, not just in its toolbox
@[AC::MCP(behaviour: [:additive, :open_world])] # replaces the verb's: :read_only :additive :destructive :idempotent :open_world :closed_world
@[AC::MCP(title: "Book a room")] # display name
@[AC::Icon(src: "icons/x.svg", sizes: ["any"])] # repeatable; https:/data: as is, ui_base file -> data:, else https://<host>/src
@[AC::MCP(endpoint: true)] # class only: also its own MCP server at <base>/mcp, base path params bound from the URL
@[AC::MCP(ui: "x/card.html")] # MCP Apps card in MCPServer.ui_base, rendered for the results (root by default)
@[AC::MCP(visibility: :card)] # only cards call it, hidden from the model (always listed); :model = cards can't
@[AC::MCP(prompt: true)] # a prompt; must return String or Array(AC::PromptMessage)
@[AC::MCP(resource: "app://w/{id}{?q}", mime_type: "application/json", cache_ttl: 0)] # GET only: also a resource template; vars = path params + optional query params
@[AC::MCP(resource: "...", subscribe: true)] # + MCPServer.watch(template) { |vars, request, changed| ...; -> { stop } }
@[AC::MCP(long_running: true, max_bytes: 1_048_576)] # spooled to a tempfile, SSE progress/keep-alive; mcp_context.progress/cancelled?/full?
- Controllers are toolboxes and each route method is one tool named
<toolbox>_<method>. The module namespace shared by all controllers is left out of the names. - WebSocket,
OPTIONSand DSL routes aren't exposed as tools. - Tool calls run the real route in-process, with the same filters and the same auth.
Authorization,CookieandX-API-Keyare forwarded. - Auth is optional:
auth_probe = "/users/current"(or a customauthenticator), plusresource_metadatafor OAuth. - Meta tools:
list_toolboxes,open_toolbox(returns tool definitions, changes nothing) and two proxies that run toolbox tools:call_read_only(read only tools,readOnlyHint: true) andcall_tool(any tool the model can see;visibility: :cardtools are refused by both). Any tool can also be called directly.tool_proxy = falseremoves toolboxes and lists every tool. Every prompt is listed. Read only = GET unless overridden withbehaviour:. Custominstructionsshould appendMCPServer.toolbox_instructions. - Stateless Streamable HTTP (
POSTonly, no sessions, any instance serves any request): protocol2026-07-28(_meta+MCP-Protocol-Version/Mcp-Method/Mcp-Nameheaders,server/discover,ttlMsfromcache_ttl) and2025-11-25/2025-06-18/2025-03-26(initialize, noMcp-Session-Id). - Resources:
resources/readruns the route in-process (404 = unknown resource, other errors are JSON-RPC errors starting with the status). Subscriptions only notify (notifications/resources/updated), the client re-reads. Listen streams end on disconnect,listen_timeout(nil = never) orTransport#close_subscriptions. Watchers only see changes on their instance, use redis pub/sub or similar across instances. - Tool results are
{status, headers, body}(text andstructuredContent). Headers matchingexcluded_response_headers(noise, cookies, credentials, transport) are left out, so return pagination as headers (Link,X-Total-Count) or in the body.
Testing#
client = AC::SpecHelper.client # in-process HTTP client
client.get("/users/1", headers: HTTP::Headers{"Accept" => "application/json"})
controller = Users.spec_instance(HTTP::Request.new("GET", "/")) # unit test an instance
Common mistakes#
| Mistake | Fix |
|---|---|
| Developer notes in the doc comment above a route | they're published to OpenAPI and MCP; separate them with a blank line |
| No return type on a route | there's no response schema, and the compiler can't check what's returned |
@[AC::Param::Info(required: true)] |
doesn't exist; make the type non-nilable without a default |
| Generating docs from a deployed binary | generate at build time where the source and crystal are available |
| An exposed route is unsafe for agents | @[AC::MCP(hide: true)] and protect it with filters |
Expecting hide: true to block access |
it only hides the MCP tool; the HTTP route still works |
| A GET route that changes data | give it a behaviour:, e.g. @[AC::MCP(behaviour: [:additive, :open_world])], or call_read_only runs it without confirmation |
No handler for Param::MissingError / ValueError |
bad input becomes a 500; add @[AC::Route::Exception] handlers in the base class |
Setting a cookie with cookies["x"] = ... |
cookies is the request's; use response.cookies << HTTP::Cookie.new(...) |
session["id"] = 42 (an Int32) |
doesn't compile; store 42_i64 |
See also#
- Routing, Parameters, Responses, Filters and Errors for the full rules behind this summary
- OpenAPI and MCP
- Configuration for the template's CLI flags and environment variables