# Spider-Gazelle > A fast, type-safe Crystal web framework with built-in OpenAPI and MCP support Spider-Gazelle is a fast, type-safe web framework for Crystal (built on the action-controller shard). Annotated controller methods are the routes, the parameter validation, the OpenAPI operations and the MCP tools, all generated from one source of truth. # Getting started # Spider-Gazelle A fast, type-safe web framework for [Crystal](https://crystal-lang.org). You write ordinary, documented methods; Spider-Gazelle turns them into HTTP routes, validated parameters, an [OpenAPI](https://spider-gazelle.net/openapi/index.md) description and [MCP](https://spider-gazelle.net/mcp/index.md) tools for AI agents, all from that one source. ## One method, four jobs ``` class Rooms < AC::Base base "/rooms" # Books a meeting room @[AC::Route::POST("/:id/bookings", body: :booking, status_code: HTTP::Status::CREATED)] def book( @[AC::Param::Info(description: "the room to book", example: "lvl3-boardroom")] id : String, booking : Booking, ) : Booking Room.find!(id).book(booking) end end ``` From that method you get: | | What you get | Driven by | | -------------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | | **HTTP route** | `POST /rooms/:id/bookings`, returning `201 Created` | the route annotation | | **Validation** | `id` and the JSON body are parsed and type checked before your code runs. Bad input gets a 4xx with a helpful error | argument types | | **OpenAPI** | an operation with a summary, a described path parameter, and request and response schemas | the doc comment, `Param::Info` and types | | **MCP tool** | `rooms_book`, which an AI agent can call with the same validation and the same filters | all of the above | Why this matters Hand-written API docs and agent tool definitions drift from the code, and drifted docs are worse than none. Spider-Gazelle derives them from the code itself. Rename a parameter, change a type or update a comment, and the OpenAPI document and MCP tools update with it. There's one source of truth to review and test, and no second copy to forget. ## Highlights - **Type-safe routing.** Parameters arrive as method arguments, already converted to `Int32`, `UUID`, `Time`, enums or your own types. There's no `params["id"]` boilerplate. - **Self documenting.** [OpenAPI 3](https://spider-gazelle.net/openapi/index.md) is generated from your comments and types. Generate API clients in any language from it. - **AI ready.** A built-in [MCP server](https://spider-gazelle.net/mcp/index.md) exposes your API to Claude, VS Code, Cursor and other agents. It includes progressive tool discovery, prompts, and OAuth sign-in. - **Content negotiation.** Request bodies are parsed by `Content-Type` and responses rendered by `Accept`. JSON is the default; add YAML, XML or your own formats. - **Fast.** It's compiled Crystal, with [LuckyRouter](https://github.com/luckyframework/lucky_router) route matching and optional multi-core execution contexts. - **Simple to test.** The spec helper drives your app in-process, with no server to start. - **You're in control.** There's no hidden magic in your project: you own the entry point, the CLI, the configuration and the server lifecycle. ## Choose your path - **New to Spider-Gazelle?** Start with [Your first app](https://spider-gazelle.net/getting_started/index.md), then work through the [Guides](https://spider-gazelle.net/guides/index.md) in order. - **Experienced Crystal developer?** Jump to [Routing](https://spider-gazelle.net/guides/routing/index.md), [Parameters](https://spider-gazelle.net/guides/parameters/index.md), [OpenAPI](https://spider-gazelle.net/openapi/index.md) and [MCP](https://spider-gazelle.net/mcp/index.md). The [API reference](https://spider-gazelle.net/Config/environment/index.md) has every macro and type. - **Building with an AI agent?** Give your agent the [agent reference](https://spider-gazelle.net/ai/index.md), or point it at [`/llms-full.txt`](https://spider-gazelle.net/llms-full.txt) for the whole site in one file. ## Built by [Place Technology](https://place.technology/), a team in Sydney and Brisbane, Australia. Spider-Gazelle powers the [PlaceOS](https://github.com/PlaceOS) smart building platform. The framework is developed on [GitHub](https://github.com/spider-gazelle). ## Example apps - [Spider-Gazelle template](https://github.com/spider-gazelle/spider-gazelle): the starting point for new apps, with OpenAPI, MCP and Docker set up. - [PlaceOS REST API](https://github.com/PlaceOS/rest-api): a large production API, with its [OpenAPI document](https://editor.swagger.io/?url=https://raw.githubusercontent.com/PlaceOS/rest-api/master/OPENAPI_DOC.yml) and an MCP server with OAuth sign-in. - [PlaceOS Staff API](https://github.com/PlaceOS/staff-api), with its [OpenAPI document](https://editor.swagger.io/?url=https://raw.githubusercontent.com/PlaceOS/staff-api/master/OPENAPI_DOC.yml). - [PlaceOS Core](https://github.com/PlaceOS/core). - [Apple/Google Wallet abstraction](https://github.com/PlaceOS/wallet). # Your first app This page takes you from an empty folder to a running Spider-Gazelle API. Start here if you're new to Spider-Gazelle, or new to Crystal. Spider-Gazelle doesn't depend on anything beyond [Crystal](https://crystal-lang.org) and its package manager, [Shards](https://crystal-lang.org/reference/the_shards_command/index.html). There are two ways to start: - **From the application template** (recommended). You get a project with logging, sessions, error handling, specs, a Dockerfile, CI, OpenAPI docs and an MCP server already wired up. - **From scratch.** You add the `action-controller` shard to a new Crystal project. This is useful for learning, or for adding an HTTP API to an existing project. ## Install Crystal Follow the [Crystal installation guide](https://crystal-lang.org/install/) for your operating system, then check it worked: ``` crystal --version shards --version ``` ## Start from the template The [spider-gazelle template](https://github.com/spider-gazelle/spider-gazelle) is a complete, working application. Clone it into a folder named after your project, and give it a fresh git history: ``` git clone https://github.com/spider-gazelle/spider-gazelle.git my_app cd my_app rm -rf .git && git init shards install crystal run src/app.cr ``` The app compiles and starts listening: ``` Launching Spider-Gazelle v2.0.0 Listening on http://127.0.0.1:3000 ``` Open and you'll see `"You're being trampled by Spider-Gazelle!"`. Try too, which returns `{"result":42}`. Note Crystal is a compiled language, so `crystal run` compiles your app before it starts. The first build takes a little longer, later builds are cached. ### Project layout | Path | Purpose | | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `src/app.cr` | Entry point. Parses the [command line options](https://spider-gazelle.net/getting_started/configuration/#command-line-options) and starts the server. | | `src/config.cr` | Requires your code and configures logging, middleware, sessions and the MCP server. | | `src/constants.cr` | App name, version and settings read from [environment variables](https://spider-gazelle.net/getting_started/configuration/#environment-variables). | | `src/controllers/application.cr` | `App::Base`, the abstract controller every other controller inherits from. Holds shared filters, responders and error handlers. | | `src/controllers/welcome.cr` | An example controller. | | `src/models/` | Your models. | | `spec/` | Your [specs](https://spider-gazelle.net/guides/testing/index.md). | | `www/` | Static files, served when no route matches. | | `Dockerfile` | Builds a small production image, see [Deployment](https://spider-gazelle.net/deployment/index.md). | | `.github/workflows/ci.yml` | Checks formatting and runs the specs on every push. | Change the app name in `src/constants.cr` (`NAME = "Spider-Gazelle"`) and the `name` in `shard.yml`. Before you deploy, set your own session secret, see [Configuration](https://spider-gazelle.net/getting_started/configuration/#sessions). ## Start from scratch Create a new Crystal app and add `action-controller` to its `shard.yml`: ``` crystal init app my_app cd my_app ``` ``` dependencies: action-controller: github: spider-gazelle/action-controller version: ~> 8.14 ``` Run `shards install`, then replace `src/my_app.cr` with: ``` require "action-controller" # Says hello class Hello < AC::Base base "/" # Returns a friendly greeting @[AC::Route::GET("/")] def index : String "Hello, World!" end end # The server must be required after your controllers require "action-controller/server" server = AC::Server.new(3000, "127.0.0.1") server.run { puts "Listening on #{server.print_addresses}" } ``` Start it with `crystal run src/my_app.cr` and request the route: ``` $ curl http://localhost:3000/ "Hello, World!" ``` The response is `"Hello, World!"` with quotes because responses are JSON by default. The client's `Accept` header selects the format, see [Responses](https://spider-gazelle.net/guides/responses/index.md). `AC` is an alias for `ActionController`, so `AC::Base` and `ActionController::Base` are the same class. ## Your first route A controller is a class that inherits from `AC::Base`, and a route is a method with a route annotation. Add a second route to the `Hello` controller: ``` # Greets someone by name, e.g. GET /greet/Steve?shout=true @[AC::Route::GET("/greet/:name")] def greet(name : String, shout : Bool = false) : String greeting = "Hello, #{name}!" shout ? greeting.upcase : greeting end ``` ``` $ curl "http://localhost:3000/greet/Steve?shout=true" "HELLO, STEVE!" ``` There's no `params["name"]` boilerplate here: - `name` matches the `:name` segment in the route path. - `shout` isn't in the path, so it's read from the query string. It has a default, so it's optional. A `Bool` is `true` when the value is `true` (in any case) and `false` otherwise. - Values are converted to the argument type. If a value can't be converted, for example `abc` for an `Int32` argument, Spider-Gazelle responds with an error instead of calling your method. That one annotated method is also your documentation. The doc comment, argument types and return type become the [OpenAPI](https://spider-gazelle.net/openapi/index.md) operation and the [MCP](https://spider-gazelle.net/mcp/index.md) tool description, so your API docs and LLM tools can't drift from the code. From here, read the guides: - [Routing](https://spider-gazelle.net/guides/routing/index.md): controllers, base paths, HTTP verbs and the route DSL. - [Parameters](https://spider-gazelle.net/guides/parameters/index.md): type conversion, `@[AC::Param::Info]` and request bodies. - [Responses](https://spider-gazelle.net/guides/responses/index.md): response formats, status codes and headers. - [Filters](https://spider-gazelle.net/guides/filters/index.md): run code before, around or after your routes. - [Errors](https://spider-gazelle.net/guides/errors/index.md): turn exceptions into consistent error responses. ## Run the specs The template ships with specs that use Crystal's built-in [spec library](https://crystal-lang.org/reference/guides/testing.html): ``` crystal spec ``` See [Testing](https://spider-gazelle.net/guides/testing/index.md) to write your own. ## Reload while you develop Crystal doesn't reload code at runtime. To rebuild and restart whenever a file changes, use a file watcher such as [watchexec](https://github.com/watchexec/watchexec) or [nodemon](https://nodemon.io/): ``` watchexec -r -e cr -- crystal run src/app.cr # or nodemon --watch src -e cr --exec crystal run src/app.cr ``` ## Build a binary For production, compile an optimised binary. With the template, `shards build` uses the `app` target in `shard.yml` and writes `bin/app`: ``` shards build --production --release ./bin/app --help ``` The binary has options for the port, host, worker count, routes listing, health checks and generating the OpenAPI and MCP description files. See [Configuration](https://spider-gazelle.net/getting_started/configuration/#command-line-options). For containers, see [Deployment](https://spider-gazelle.net/deployment/index.md). ## See also - [Configuration](https://spider-gazelle.net/getting_started/configuration/index.md): environment variables, `config.cr` and command line options - [Routing](https://spider-gazelle.net/guides/routing/index.md) - [Testing](https://spider-gazelle.net/guides/testing/index.md) - [Deployment](https://spider-gazelle.net/deployment/index.md) - [OpenAPI](https://spider-gazelle.net/openapi/index.md) and [MCP](https://spider-gazelle.net/mcp/index.md) # Configuration This page covers how a Spider-Gazelle app is configured: environment variables, `src/config.cr` and the command line options of the compiled binary. It follows the layout of the [application template](https://github.com/spider-gazelle/spider-gazelle), which most apps start from. ## Quick example Every setting has a default, so the app runs without any configuration. In production, set the environment and a session secret, and bind to all interfaces: ``` export SG_ENV=production export COOKIE_SESSION_SECRET="$(openssl rand -hex 16)" ./bin/app -b 0.0.0.0 -p 8080 -w 4 ``` ## How the template is organised The template splits configuration over three files. Each has one job: | File | Job | | ------------------ | ------------------------------------------------------------------------------------------------------- | | `src/constants.cr` | Reads environment variables into constants such as `App::DEFAULT_PORT`. Has no side effects. | | `src/app.cr` | Parses the command line, then requires `config.cr` and starts the server. | | `src/config.cr` | Requires your controllers and models, then configures logging, middleware, the MCP server and sessions. | `app.cr` parses the command line **before** it requires `config.cr`. This means options such as `--routes` and `--help` exit before any database connections or other start-up work in `config.cr` happens. Specs require `config.cr` directly, so your tests get the same configuration without starting a server. ## Environment variables These are read in `src/constants.cr`. Command line options take precedence over the port, host and worker count. | Variable | Default | Purpose | | ----------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------- | | `SG_ENV` | `development` | Set to `production` for production behaviour, see below. | | `SG_SERVER_HOST` | `127.0.0.1` | Address to bind. Use `0.0.0.0` in containers. | | `SG_SERVER_PORT` | `3000` | Port to bind. | | `SG_WORKER_COUNT` | `1` | Number of threads handling requests, see [Workers and threads](#workers-and-threads). | | `PUBLIC_WWW_PATH` | `./www` | Folder of static files. Ignored if the folder doesn't exist. | | `COOKIE_SESSION_KEY` | `_spider_gazelle_` | Name of the session cookie. | | `COOKIE_SESSION_SECRET` | a fixed example value | Secret that encrypts and signs the session cookie. Must be at least 32 bytes. | | `SG_MCP_PATH` | `/mcp` | Path of the [MCP](https://spider-gazelle.net/mcp/index.md) endpoint. An empty string disables it. | | `SG_MCP_DESCRIPTION` | `mcp.yml` | Location of the generated MCP tool descriptions. | | `SG_MCP_UI` | `./cards` | Folder of [UI cards](https://spider-gazelle.net/mcp/ui/index.md), HTML rendered by MCP clients for tool results. | Warning The default `COOKIE_SESSION_SECRET` is in the public template, so anyone can forge your session cookies with it. Always set your own secret in production. ### Production mode When `SG_ENV=production`, `App.running_in_production?` returns `true` and the template: - logs at `info` for your app and action-controller, and `warn` for everything else (instead of `debug` and `info`), see [Logging](https://spider-gazelle.net/guides/logging/index.md) - uses the production error handler, which doesn't send exception details or backtraces to the client - marks the session cookie `Secure`, so browsers only send it over HTTPS Use `App.running_in_production?` in your own code for anything else that differs between environments. ## config.cr This is the template's `src/config.cr`, section by section. ### Requires ``` require "action-controller" require "./constants" require "./controllers/application" require "./controllers/*" require "./models/*" # Server required after application controllers require "action-controller/server" # Exposes the application routes to LLM clients require "action-controller/mcp" ``` `action-controller/server` must be required **after** your controllers. Routes are collected at compile time, and the server only sees controllers defined before it is required. ### Logging ``` if running_in_production? log_level = ::Log::Severity::Info ::Log.setup "*", :warn, LOG_BACKEND else log_level = ::Log::Severity::Debug ::Log.setup "*", :info, LOG_BACKEND end ::Log.builder.bind "action-controller.*", log_level, LOG_BACKEND ::Log.builder.bind "#{NAME}.*", log_level, LOG_BACKEND ``` See [Logging](https://spider-gazelle.net/guides/logging/index.md) for log sources, request IDs, JSON output and changing the level at runtime. ### Middleware ``` filter_params = ["password", "bearer_token"] keeps_headers = ["X-Request-ID"] ActionController::Server.before( ActionController::ErrorHandler.new(running_in_production?, keeps_headers), ActionController::LogHandler.new(filter_params), HTTP::CompressHandler.new ) ``` `ActionController::Server.before` adds standard Crystal [HTTP handlers](https://crystal-lang.org/api/latest/HTTP/Handler.html) that run, in order, before your routes. `Server.after` adds handlers that only run when no route matches. Handlers must be added before the server is created. - `ErrorHandler.new(production, persist_headers)` turns unhandled exceptions into `500` responses. In development it renders a detailed exception page. Headers named in `persist_headers` are kept on the error response, so clients still get their `X-Request-ID`. - `LogHandler.new(filter)` logs every response. Query string params named in `filter` are logged as `[FILTERED]`. - `HTTP::CompressHandler` gzips or deflates responses when the client supports it. ### Static files ``` if File.directory?(STATIC_FILE_PATH) ::MIME.register(".yaml", "text/yaml") ActionController::Server.before( ::HTTP::StaticFileHandler.new(STATIC_FILE_PATH, directory_listing: false) ) end ``` Files in `./www` (or `PUBLIC_WWW_PATH`) are served when a request doesn't match a route. ### MCP server ``` ActionController::MCPServer.tap do |mcp| mcp.server_name = NAME mcp.server_version = VERSION mcp.description_path = ENV["SG_MCP_DESCRIPTION"]? || "mcp.yml" # UI cards, `@[AC::MCP(ui: "welcome/result.html")]` renders cards/welcome/result.html mcp.ui_base = MCP_UI_PATH if File.directory?(MCP_UI_PATH) mcp.ui_meta = ActionController::MCPServer::UIMeta.new(prefers_border: true) end ``` `app.cr` mounts the MCP server at `SG_MCP_PATH`. The options, including authentication, are covered in the [MCP guide](https://spider-gazelle.net/mcp/index.md). ### Sessions ``` ActionController::Session.configure do |settings| settings.key = COOKIE_SESSION_KEY settings.secret = COOKIE_SESSION_SECRET # HTTPS only: settings.secure = running_in_production? end ``` `key` and `secret` have no defaults in action-controller, so they must be set before a session is used. See [Sessions and cookies](https://spider-gazelle.net/guides/sessions/index.md) for every option. ## Command line options These are defined in the template's `src/app.cr`. Run `./bin/app --help` to list them. | Option | Description | | ----------------------------- | --------------------------------------------------------------------------------------------------------- | | `-b HOST`, `--bind=HOST` | Address to bind. Default `SG_SERVER_HOST`, else `127.0.0.1`. | | `-p PORT`, `--port=PORT` | Port to bind. Default `SG_SERVER_PORT`, else `3000`. | | `-w COUNT`, `--workers=COUNT` | Number of threads handling requests. Default `SG_WORKER_COUNT`, else `1`. `0` or less uses the CPU count. | | `-r`, `--routes` | Prints the routes, then exits. | | `-v`, `--version` | Prints the app name and version, then exits. | | `-c URL`, `--curl=URL` | Requests `URL` as a health check, then exits. See below. | | `-d`, `--docs` | Prints the [OpenAPI](https://spider-gazelle.net/openapi/index.md) document as YAML, then exits. | | `-f FILE`, `--file=FILE` | With `--docs`, writes the document to `FILE` instead. Must come **after** `--docs`. | | `--mcp=FILE` | Writes the [MCP](https://spider-gazelle.net/mcp/index.md) tool descriptions to `FILE`, then exits. | | `-h`, `--help` | Prints the options, then exits. | ### Listing routes ``` $ ./bin/app --routes Controller#Action Verb URI Pattern App::Welcome#index get / App::Welcome#api get /api/:example App::Welcome#api get /api/other/route App::Welcome#api post /api/:example App::Welcome#openapi get /openapi ``` ### Generating OpenAPI and MCP descriptions ``` ./bin/app --docs --file=openapi.yml ./bin/app --mcp=mcp.yml ``` Both read the doc comments in your source code by running `crystal docs`. So they must be run from the project root, on a machine with `crystal` installed. That's why the template's [Dockerfile](https://spider-gazelle.net/deployment/index.md) generates both files during the build stage and copies them into the final image. Note `--file` is only registered once `--docs` has been parsed. `--docs --file=x.yml` works, `--file=x.yml --docs` doesn't. It's also why `--file` isn't listed by `--help`. ### Health checks ``` ./bin/app -c http://127.0.0.1:3000/ ``` `-c` requests the URL using Crystal's built-in HTTP client, so you don't need `curl` in your container image. It exits with: | Exit code | Meaning | | --------- | ----------------------------------------------------------- | | `0` | The response status was between 200 and 499. | | `1` | Any other status, for example a 5xx. | | `2` | The request failed, for example the connection was refused. | A 4xx counts as healthy because the server answered. Point it at a route that doesn't need authentication. See [Deployment](https://spider-gazelle.net/deployment/#health-checks). ### Workers and threads `-w` (or `SG_WORKER_COUNT`) scales your app across CPU cores with threads. A single process runs your requests on several threads: ``` server.threads(thread_count) ``` `threads` resizes Crystal's default execution context to `count` threads. `0` or less uses the CPU count, and `1` (the default) keeps the app single threaded. Threads share memory: a cache, or the in-memory sessions of the MCP server, is shared by every request. Any state you change from requests, such as class variables or mutable constants, must be protected with a `Mutex` (or held in a database or Redis). Processes instead of threads The template also includes a commented-out `server.cluster(thread_count, "-w", "--workers")`, which starts separate processes sharing the port instead. Forking is deprecated in Crystal, so prefer threads. With processes nothing is shared, so caches and MCP sessions exist once per process. `threads` resizes Crystal's default [execution context](https://crystal-lang.org/reference/latest/guides/concurrency.html). Check the Crystal documentation for the compiler flags your Crystal version needs for multi-threading. ### Signals - `SIGTERM`, `SIGINT` (Ctrl+C) and `SIGHUP` close the server gracefully. - `SIGUSR1` toggles `trace` logging for your app's logs, see [Logging](https://spider-gazelle.net/guides/logging/#changing-the-log-level-at-runtime). ## Server options If you don't use the template, create and run the server yourself: ``` require "action-controller" # ... require your controllers ... require "action-controller/server" server = ActionController::Server.new(port: 3000, host: "0.0.0.0") server.run { puts "Listening on #{server.print_addresses}" } ``` To serve HTTPS directly, pass an `OpenSSL::SSL::Context::Server` as the first argument: `ActionController::Server.new(ssl_context, 3443, "0.0.0.0")`. Most deployments terminate TLS at a load balancer or reverse proxy instead. ## See also - [Your first app](https://spider-gazelle.net/getting_started/index.md) - [Logging](https://spider-gazelle.net/guides/logging/index.md) - [Sessions and cookies](https://spider-gazelle.net/guides/sessions/index.md) - [Deployment](https://spider-gazelle.net/deployment/index.md) - [OpenAPI](https://spider-gazelle.net/openapi/index.md) and [MCP](https://spider-gazelle.net/mcp/index.md) # Guides # Guides The guides explain each part of a Spider-Gazelle app. If you're new, read them in order. Each builds on the last. If you're experienced, jump to the topic you need. | Guide | You'll learn | | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | [Controllers & routing](https://spider-gazelle.net/guides/routing/index.md) | defining routes with annotations, paths, verbs and redirect helpers | | [Composable applications](https://spider-gazelle.net/guides/composition/index.md) | combining apps, mounting controller subtrees, unified catalogs and the tradeoffs of handler chains | | [Parameters](https://spider-gazelle.net/guides/parameters/index.md) | typed parameters, converters, headers and request bodies | | [Responses](https://spider-gazelle.net/guides/responses/index.md) | responders, status codes, rendering, files and headers | | [Filters](https://spider-gazelle.net/guides/filters/index.md) | running code before, around and after actions | | [Error handling](https://spider-gazelle.net/guides/errors/index.md) | turning exceptions into helpful responses | | [Sessions & cookies](https://spider-gazelle.net/guides/sessions/index.md) | storing small amounts of state between requests | | [WebSockets](https://spider-gazelle.net/guides/websockets/index.md) | realtime connections | | [Logging](https://spider-gazelle.net/guides/logging/index.md) | structured logs, request IDs and changing log levels at runtime | | [Testing](https://spider-gazelle.net/guides/testing/index.md) | testing routes, controllers and your MCP server | Everything you write here feeds OpenAPI and MCP The doc comments, `@[AC::Param::Info]` annotations, argument types and return types you'll meet in these guides also generate your [OpenAPI](https://spider-gazelle.net/openapi/index.md) document and [MCP tools](https://spider-gazelle.net/mcp/index.md). Writing a good route is writing good documentation. # Controllers and routing A controller is a class that groups related routes, and a route is a method with an annotation saying which HTTP verb and path it handles. This page covers defining controllers, choosing their paths and matching requests to methods. ``` require "action-controller" # Manages the comments on articles class Comments < AC::Base base "/comments" # Lists the comments @[AC::Route::GET("/")] def index : Array(String) ["first!", "great article"] end # Returns a single comment @[AC::Route::GET("/:id")] def show(id : Int64) : String "comment #{id}" end end require "action-controller/server" AC::Server.new.run # GET /comments # => ["first!","great article"] # GET /comments/42 # => "comment 42" ``` The `id` in the path is matched to the `id` argument by name and converted to an `Int64` before the method runs. A request for `/comments/abc` never reaches your code. See [parameters](https://spider-gazelle.net/guides/parameters/index.md) for how arguments are parsed. That one annotated method is the route, its parameter parsing and validation, its [OpenAPI](https://spider-gazelle.net/openapi/index.md) operation and its [MCP](https://spider-gazelle.net/mcp/index.md) tool. The doc comment above it becomes the operation summary and the tool description. ## Controllers Every controller inherits from `AC::Base` (an alias for `ActionController::Base`). Most applications define an abstract base class for shared filters, responders and error handlers, and inherit from it: ``` require "action-controller" # Abstract classes don't generate routes abstract class Application < AC::Base # shared filters and error handlers go here end class Users < Application base "/users" @[AC::Route::GET("/")] def index : Array(String) ["alice", "bob"] end end ``` Only concrete (non-abstract) classes generate routes. Filters, exception handlers and routes are all inherited, so a route defined on a parent is available on every concrete subclass at the subclass's base path. Rules to keep in mind: - An annotated method name must be unique within a class hierarchy. Redefining an annotated method that a parent already defines is a compile error. - A route method can't accept a block. Only around filters take a block. - Controller methods without an annotation are ordinary methods and aren't routable. ## Base path `base` sets the path prefix for every route in the controller. If you leave it out, it's derived from the class name, with `::` becoming `/`: | Class | Default base | | ------------------- | --------------------- | | `Users` | `/users` | | `ExampleController` | `/example_controller` | | `Api::V1::Users` | `/api/v1/users` | ``` class Welcome < AC::Base # serve from the root of the site base "/" @[AC::Route::GET("/")] def index : String "Hello World" end end ``` The base can contain path parameters too. They're available to every route in the controller: ``` class Features < AC::Base base "/photos/:photo_id/features" # GET /photos/:photo_id/features @[AC::Route::GET("/")] def index(photo_id : Int64) : Array(String) ["faces", "landmarks"] end # GET /photos/:photo_id/features/:id @[AC::Route::GET("/:id")] def show(photo_id : Int64, id : Int64) : String "feature #{id} of photo #{photo_id}" end end ``` ## HTTP verbs | Annotation | Handles | | ------------------------------- | ----------------------------------------------------------------------------------------------------- | | `@[AC::Route::GET(path)]` | `GET`, and `HEAD` automatically | | `@[AC::Route::POST(path)]` | `POST` | | `@[AC::Route::PUT(path)]` | `PUT` | | `@[AC::Route::PATCH(path)]` | `PATCH` | | `@[AC::Route::DELETE(path)]` | `DELETE` | | `@[AC::Route::OPTIONS(path)]` | `OPTIONS` | | `@[AC::Route::WebSocket(path)]` | WebSocket upgrades (a `GET`), see [WebSockets](https://spider-gazelle.net/guides/websockets/index.md) | Every `GET` route also answers `HEAD` requests. The method runs as normal, and the status and headers are sent without a body. ``` class Articles < AC::Base base "/articles" @[AC::Route::GET("/")] def index : Array(String) ["hello"] end @[AC::Route::POST("/")] def create : String "created" end @[AC::Route::PUT("/:id")] def replace(id : Int64) : String "replaced #{id}" end @[AC::Route::PATCH("/:id")] def update(id : Int64) : String "updated #{id}" end @[AC::Route::DELETE("/:id")] def destroy(id : Int64) head :no_content end end ``` ## Path patterns Paths are matched by [LuckyRouter](https://github.com/luckyframework/lucky_router). A path is joined to the controller's base, and each segment can be: | Segment | Meaning | Example path | Matches | | -------- | ------------------------------------------------- | --------------- | -------------------------------------------- | | `name` | literal text | `/users` | `/users` | | `:name` | required parameter | `/users/:id` | `/users/42` | | `?:name` | optional parameter | `/users/?:id` | `/users` and `/users/42` | | `*:name` | glob, captures the rest of the path including `/` | `/files/*:path` | `/files/a/b/c.txt` (`path` is `"a/b/c.txt"`) | ``` class Files < AC::Base base "/files" # GET /files/docs/readme.md # => "docs/readme.md" @[AC::Route::GET("/*:path")] def show(path : String) : String path end # GET /files/versions # => "latest" # GET /files/versions/3 # => "3" @[AC::Route::GET("/versions/?:version")] def version(version : Int32? = nil) : String version ? version.to_s : "latest" end end ``` A method argument receives the path parameter with the same name. An optional parameter's argument must be nilable or have a default. Path parameters take precedence over query parameters with the same name. Optional segments match where they're written, one after another: `/users/?:user_id/groups/?:group_id` matches `/users/groups`, `/users/5/groups` and `/users/5/groups/9`. The OpenAPI document lists each of these paths. Paths without parameters match with or without a trailing `/`. Routes must not overlap. Two routes for the same verb whose paths differ only in a parameter's name, such as `/photos/:id/features` and `/photos/:photo_id/features`, raise `LuckyRouter::DuplicateRouteError` when the server starts. ## Multiple routes per method Stack annotations to serve several paths from one method: ``` class Groups < AC::Base base "/" @[AC::Route::GET("/users/:user_id/groups")] @[AC::Route::GET("/groups")] def index(user_id : Int64? = nil) : String user_id ? "groups for user #{user_id}" : "all groups" end end ``` When the paths differ only by an optional segment, `?:name` is simpler: `/users/?:user_id/groups` matches both `/users/groups` and `/users/5/groups`. Stacked annotations suit paths with different shapes, or routes that need different options such as `config:`. - Each annotation is a separate operation in the OpenAPI document. - In [MCP](https://spider-gazelle.net/mcp/index.md), a method is a single tool however many routes it has. The tool uses the method's first `GET` route, the same one the [path helper](#building-paths-to-routes) builds. ## Route options Route annotations accept named options after the path. Each is covered in detail on the linked page. | Option | Example | Purpose | | -------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `body:` | `body: :comment` | Parse the request body into this argument. See [parameters](https://spider-gazelle.net/guides/parameters/#request-bodies). | | `status_code:` | `status_code: HTTP::Status::CREATED` | The response status, `200 OK` by default. See [responses](https://spider-gazelle.net/guides/responses/#status-codes). | | `status:` | `status: {Error => HTTP::Status::NOT_FOUND}` | Map return types to status codes. See [responses](https://spider-gazelle.net/guides/responses/#status-codes). | | `content_type:` | `content_type: "text/plain"` | Always respond with this content type, skipping `Accept` negotiation. See [responses](https://spider-gazelle.net/guides/responses/#fixed-content-types). | | `config:` | `config: {id: {base: 16}}` | Options for a parameter's converter. See [parameters](https://spider-gazelle.net/guides/parameters/#converter-options). | | `converters:` | `converters: {tags: ConvertTags}` | Use a custom converter for a parameter. See [parameters](https://spider-gazelle.net/guides/parameters/#custom-converters). | | `map:` | `map: {per_page: :perPage}` | Read an argument from a differently named parameter. See [parameters](https://spider-gazelle.net/guides/parameters/#renaming-parameters). | | `response_type:` | `response_type: Array(Comment)` | The response type used in the OpenAPI document, when it differs from the method's return type. | | `execution_context:` | `execution_context: "reports"` | Run the route on a named execution context. See [execution contexts](https://github.com/spider-gazelle/action-controller/blob/master/CONTEXTS.md). | ## Building paths to routes Every concrete controller gets a class method for each route method, named after the method. It builds the path, filling in path parameters from named arguments and adding any others as a query string: ``` Features.show(photo_id: 1, id: 2) # => "/photos/1/features/2" Features.show(photo_id: 1, id: 2, full: true) # => "/photos/1/features/2?full=true" Features.show(photo_id: 1) # raises AC::InvalidRoute (missing :id) ``` Arguments must be named. Combine the helper with `redirect_to`: ``` @[AC::Route::POST("/:photo_id/features")] def create(photo_id : Int64) : Nil redirect_to Features.show(photo_id: photo_id, id: 3), status: :see_other end ``` If a method has several routes, the helper builds just one of them: the first `GET` route. Without a `GET`, it's the first route in verb order (`POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`), not source order. Build paths to the others yourself. ## Macro DSL Under the annotations is a lower-level macro DSL. It defines a route from a block, and you read parameters from `params` and send the response with `render` yourself: ``` class Photos < AC::Base base "/photos" # GET /photos/:id/features get "/:id/features", :features do id = params["id"] render json: ["faces in photo #{id}"] end # POST /photos/:id/feature post "/:id/feature", :add_feature do head :created end end ``` The second argument names the generated method, so filters can refer to it with `only:` and `except:`. The macros are `get`, `post`, `put`, `patch`, `delete`, `options` and `ws`. Prefer annotations. DSL routes get no typed parameters or validation, and they don't appear in the OpenAPI document or as MCP tools. ## Listing routes `AC::Server.routes` returns every route as `{controller, method, verb, path}` tuples, and `AC::Server.print_routes` prints them as a table. The [application template](https://spider-gazelle.net/getting_started/index.md) wires this to a flag: ``` ./bin/app --routes ``` ## See also - [Composable applications](https://spider-gazelle.net/guides/composition/index.md): mounting and combining controller subtrees - [Parameters](https://spider-gazelle.net/guides/parameters/index.md): types, converters, headers and request bodies - [Responses](https://spider-gazelle.net/guides/responses/index.md): status codes, responders and content types - [Filters](https://spider-gazelle.net/guides/filters/index.md): code that runs before, around and after routes - [Errors](https://spider-gazelle.net/guides/errors/index.md): turning exceptions into responses - [WebSockets](https://spider-gazelle.net/guides/websockets/index.md) - [OpenAPI](https://spider-gazelle.net/openapi/index.md) and [MCP](https://spider-gazelle.net/mcp/index.md) # Composable applications Action Controller **9.0.0** lets a Spider-Gazelle application reuse another app's controllers, serve several apps from one process, and mount an app at a new public path. The same composition describes HTTP routes, OpenAPI operations and MCP tools. Existing apps can keep their template startup and automatic controller discovery. An application boundary is a controller base class, often an abstract `App::Base`. Selecting that base selects its concrete descendants and their routes. Each app can keep its own inherited filters, responders and exception handlers. ## Choose how to combine apps For a combined API with one OpenAPI document and one global MCP server, start with `AC::Server.compose`. Add mounts when you need to change public paths. Use a handler chain when ordered fallback between independent routers is part of the design. | Option | Useful for | Upsides | Tradeoffs | | -------------------------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | Automatic discovery | An existing single app | Existing startup and catalog calls keep working | Requiring a controller library can expose more routes than intended; application roots are implicit | | `AC::Server.compose(...)` | One combined server using the template | Explicit roots, one routing table, unified OpenAPI/MCP and CLI output | Declared once per compiled application; conflicting public operations must be resolved | | `AC::Composition.new(...)` | Several server instances or different catalogs in one binary | Each instance selects its own roots and public placements | Pass the composition to servers, generators and tests; it does not configure the template's default CLI output | | `App::Base.handler` in an HTTP handler chain | Ordered fallback, overlapping local routes, or integration with other Crystal handlers | Each app matches independently; the first match wins | Each miss tries another router; a raw chain does not automatically produce a shared catalog or global MCP server | **Mounting is available within these compositions.** A mount changes where a controller subtree is exposed. It gives reusable apps stable internal definitions and host-chosen public URLs, but callers must use mount-aware URL helpers and the host must coordinate middleware and global configuration. ## Reuse controllers as a library A reusable app should expose a library entry point that requires its controller base, controllers and supporting models. For example, a shard might provide `require "other_app/controllers"`, defining `OtherAC::App::Base` and its descendants. Keep executable startup and global configuration in the standalone app's entry point. The host requires the controller library and decides which server to create, which middleware to install, and how to configure sessions and MCP. Requiring a library should not also start a server or replace those settings. You can continue to run the reusable app standalone with its existing template. Compilation includes the controllers; each composition selects the subtree it serves at initialization. ## One combined server In the host's `src/config.cr`, require both apps' controllers, then declare their roots: ``` require "action-controller" require "./controllers/application" require "./controllers/*" require "other_app/controllers" # your reusable shard's entry point require "action-controller/server" require "action-controller/mcp" AC::Server.compose(App::Base, OtherAC::App::Base) ``` `compose` is a compile-time declaration. Declare it once, after requiring the controller definitions. It selects the default composition used by `AC::Server`, OpenAPI, MCP and the spec helper. The server registers all selected placements in one routing table; it does not try a separate app router for each request. The template's existing server startup can stay as it is: ``` server = AC::Server.new(port, host) AC::MCPServer.mount(server, "/mcp") server.run ``` Its command line options also use the selected roots: ``` ./bin/app --routes ./bin/app --docs --file=openapi.yml ./bin/app --mcp=mcp.yml ``` Although the template parses these options before `config.cr` is initialized, the compiler has already processed the `compose` declaration. Catalog generation can therefore exit before database connections or other configuration side effects. If you omit `compose`, existing automatic discovery continues. Controllers used only as mount targets are excluded from standalone automatic discovery, so their original routes are not also exposed accidentally. Explicitly selecting an app includes its subtree independently of mounts declared by unrelated apps. ## Mount at a new base path Suppose a reusable library defines this controller: ``` module OtherAC module App abstract class Base < AC::Base base "/oauth2" end class OAuth2 < Base base "/oauth2" @[AC::Route::GET("/login")] def login : String "Sign in" end @[AC::Route::POST("/token")] def token : String "Issue a token" end end end end ``` The host chooses its public location: ``` class MyApp < AC::Base base "/myapp/" mount "/auth/", OtherAC::App::OAuth2 end AC::Server.compose(MyApp) ``` The mount **replaces** the target's base. The routes become `GET /myapp/auth/login` and `POST /myapp/auth/token`; the `/oauth2` base is removed. Slashes are normalized. The mount declaration belongs to the host controller and its path is relative to that controller's public base. A target can be a concrete controller or an abstract application base. Mounting an app includes its descendants, preserving their paths relative to the app's base: | Original definition | Mounted at `/service` | | ---------------------------------- | --------------------- | | App base `/api` | `/service` | | Descendant controller `/api/users` | `/service/users` | | Descendant controller `/health` | `/service/health` | A descendant outside the app's base retains its entire path below the mount. `base` still sets each controller's own path; it does not implicitly prefix descendant controller bases. Mounts can be nested and repeated: ``` class Gateway < AC::Base base "/gateway" mount "/primary", MyApp mount "/secondary", MyApp end ``` Selecting `Gateway` exposes the login action at both `/gateway/primary/auth/login` and `/gateway/secondary/auth/login`. Each placement has its own public path, while the controller definition remains reusable. Targets can be named relative to the declaring controller's namespace and can be declared later in the source. Mount cycles, conflicting public operations and ambiguous path parameter names are rejected. Conflict checks include equivalent parameterized paths and GET's generated HEAD operation. Mounting is not a way to choose an order between conflicting routes; use distinct bases or a handler chain when ordered fallback is required. ### Filters and request paths Mounted actions run the target controller's own filters, responders and exception handlers, including those inherited from its controller base. The mounting controller's filters apply to its own actions. Mounting another subtree does not make its controllers inherit the host's filters. Put cross-app policy in shared middleware or a controller base that the apps actually inherit. The host owns global logging, compression, sessions, MCP authentication/configuration and UI asset locations. See [filters](https://spider-gazelle.net/guides/filters/index.md) and [configuration](https://spider-gazelle.net/getting_started/configuration/index.md). Requests keep their public `request.path`; mounting does not strip a prefix or rewrite the request. The controller sees its public base through the instance `base_route`. Explicit compositions and mounted apps isolate matched routes from path bindings left by upstream handlers. A miss preserves the context for the next handler. Ordinary automatic routing retains its existing binding behavior. ### Parameterized mounts A mount can add parameters that actions or filters previously received from the query string: ``` class Reports < AC::Base base "/reports" @[AC::Route::GET("/summary")] def summary(account_id : Int64) : String "Report for account #{account_id}" end end class Accounts < AC::Base base "/accounts" mount "/:account_id/reports", Reports end ``` Selecting `Accounts` exposes `GET /accounts/42/reports/summary`. The bound `account_id` is converted to `Int64` for the action, and OpenAPI and MCP describe the public parameter. Required parameters in a target's original path must remain required parameters in the replacement path, with the same names. A mount cannot remove a required parameter or make it optional. If a replaced base contained optional parameters, declared action arguments can fall back to query parameters. Optional mount segments (`?:name`) preserve the action's argument requirements: an argument required by the action remains required when the segment is omitted, so the caller must provide it through its query form. At a parameterized MCP controller endpoint, only arguments bound by the current session URL are removed from that session's tool schema. Other arguments remain available, and bound URL values take precedence when tools or prompts are invoked. ## Generate URLs for a placement Inside an action, use `route_path` to build a URL with the current mounted base and bound path parameters: ``` route_path(:login) # inside the mounted OAuth2 controller # => "/myapp/auth/login" ``` You can use that result with `redirect_to`. Explicit helper arguments override bound values, for example `route_path(:summary, account_id: 7)`. Individual path segments are encoded; optional and glob segments are supported. Later optional segments require values for earlier optional segments. Existing class helpers such as `OtherAC::App::OAuth2.login` still produce the original `/oauth2/login` URL. Outside a request, use a composition to choose the public placement: ``` composition = AC::Composition.new([MyApp.name]) composition.url_for(OtherAC::App::OAuth2, :login) # => "/myapp/auth/login" gateway = AC::Composition.new([Gateway.name]) gateway.url_for( OtherAC::App::OAuth2, :login, mount_base: "/gateway/secondary/auth", ) # => "/gateway/secondary/auth/login" ``` If a controller has several placements, `url_for` requires `mount_base:` to select one. For parameterized mounts, `mount_base:` is the placement's path pattern; pass the concrete parameter values as helper arguments. ## Independent compositions and unified catalogs Create composition instances when different servers or generated catalogs need different selections. Roots are class **names** here; `Server.compose` takes class constants. ``` # After requiring the controller libraries require "action-controller/server" require "action-controller/mcp" 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.0", composition: composition, ) File.write("openapi.yml", docs.to_yaml) AC::MCPServer.write_description("mcp.yml", composition: composition) ``` The server's global MCP endpoint infers its composition from the server. OpenAPI and description generation receive the same instance explicitly. Different compositions can expose apps with identical original URLs on separate servers, because each has its own routing table and catalog selection. OpenAPI uses the public mounted paths and distinct operation IDs. MCP tools, prompts, instructions and annotated controller endpoints use those same placements. Repeated mounts receive separate toolboxes and unique tool/prompt names. Existing MCP visibility annotations still apply. See [OpenAPI generation](https://spider-gazelle.net/openapi/generating/index.md), [MCP setup](https://spider-gazelle.net/mcp/setup/index.md) and [controller endpoints](https://spider-gazelle.net/mcp/endpoints/index.md). Generate catalog files where source comments and the Crystal compiler are available, as described in those guides. Regenerate `mcp.yml` when selections or mounts change. Its composition identity detects stale files; a mismatch falls back to descriptions from compiled metadata, which do not retain source comments. Legacy files remain supported for ordinary apps without explicit composition or mounts. Description caches are scoped to the composition and description file. Instance selections do not configure the template's default `--routes`, `--docs` and `--mcp` options. Use `Server.compose` for those defaults, or add generation code that passes your chosen instance explicitly. ### Test the same composition ``` require "action-controller/spec_helper" composition = AC::Composition.new([MyApp.name]) client = AC::SpecHelper.new(composition).hot_topic response = client.get("/myapp/auth/login") ``` For direct controller tests, `spec_instance` accepts `composition:` too. It binds the requested placement's base and path parameters without executing actions or filters. Existing test calls use the configured default composition. See [testing](https://spider-gazelle.net/guides/testing/index.md). ## Ordered HTTP handler chains Each controller class provides `.handler`, returning a fresh `HTTP::Handler` for that class and its concrete descendants. This also works on an abstract app base: ``` require "action-controller" # Require both apps' controller libraries here server = HTTP::Server.new([ App::Base.handler, OtherAC::App::Base.handler, HTTP::StaticFileHandler.new("www", directory_listing: false), ]) server.bind_tcp("127.0.0.1", 3000) server.listen ``` Handlers are tried in order. A route miss calls the next handler, which can be another app, a static file handler or any Crystal HTTP handler. A matched action keeps its response, including an intentional 404 or an authentication failure. Response status does not turn a match into a miss. An app's controller filters run only after one of its routes matches. Overlapping paths across different handlers are allowed: the first matching app owns that request. Use fresh handlers for each chain or server because a handler has a mutable `next` link. Request controller instances continue to be constructed with an `HTTP::Server::Context`. A raw `HTTP::Server` chain has no combined composition for catalog generation. Its handler order and overlapping routes cannot be represented as distinct operations at the same public method/path in one OpenAPI document. If unified OpenAPI and a global MCP server are required, choose nonconflicting public paths and use one composition for the server and catalogs. You can also put ordinary middleware before that server's routing table and a fallback handler after it with `AC::Server.before` and `AC::Server.after`. ## Performance and operational tradeoffs Root selection, mount expansion, validation and catalog projection happen at compilation or initialization. A unified composition flattens the public routes into one router. A mounted request uses that router and binds its public base in the context; it does not walk mount declarations or allocate a placement per request. Handler chains pay another lookup for every app that misses. The 9.0.0 router uses an allocation-free exact static lookup and lazy trie matching for unescaped dynamic paths. Only successful captures become strings; escaped paths retain LuckyRouter's decoder. Dispatch remains through `Proc`, which won the measured mixed-target comparisons against callable objects and generated integer dispatch. Local release benchmarks found no consistent warmed single-handler or mounted-app dispatch regression in the tested fixtures. Ordinary dynamic requests allocated 32–64 fewer bytes, and nested full-request dispatch improved about 1.6–2.8%. These measurements exclude startup and network I/O and do not guarantee zero overhead for every workload. Parameterized mounts, mounted URL generation, concurrent MCP requests and real network traffic need their own measurements. See the [benchmark fixtures and results](https://github.com/spider-gazelle/action-controller/blob/v9.0.0/benchmarks/README.md). All apps in one process share dependencies and global settings. Compositions select routes; they do not isolate mutable globals, databases, sessions or MCP configuration. If apps need separate process-wide settings or independent deployment, run them in separate processes and route traffic at a proxy. ## See also - [Controllers and routing](https://spider-gazelle.net/guides/routing/index.md) - [Configuration](https://spider-gazelle.net/getting_started/configuration/index.md) - [Filters](https://spider-gazelle.net/guides/filters/index.md) - [OpenAPI generation](https://spider-gazelle.net/openapi/generating/index.md) - [MCP setup](https://spider-gazelle.net/mcp/setup/index.md) - [Testing](https://spider-gazelle.net/guides/testing/index.md) # Parameters Route methods declare the values they need as typed arguments. Spider-Gazelle finds each value in the path, query string, form data or headers, converts it to the argument's type and rejects the request if it can't. This page covers where values come from, which types are supported, how to customise parsing and how to read request bodies. ``` require "action-controller" class Search < AC::Base base "/search" # Searches the catalogue @[AC::Route::GET("/:category")] def index( category : String, @[AC::Param::Info(description: "the text to search for", example: "crystal")] q : String, @[AC::Param::Info(description: "results per page")] limit : Int32 = 20, in_stock : Bool? = nil, ) : String "#{category}: #{q}, limit #{limit}, in stock #{in_stock.inspect}" end end require "action-controller/server" AC::Server.new.run # GET /search/books?q=crystal # => "books: crystal, limit 20, in stock nil" # GET /search/books?q=crystal&limit=5&in_stock=true # # => "books: crystal, limit 5, in stock true" # GET /search/books # => AC::Route::Param::MissingError (q is required) # GET /search/books?q=crystal&limit=many # => AC::Route::Param::ValueError (limit isn't an Int32) ``` There's no `params["limit"].to_i` boilerplate. The method signature describes the request, and the same signature, with the `@[AC::Param::Info]` descriptions and examples, becomes the parameters in the [OpenAPI](https://spider-gazelle.net/openapi/descriptions/#parameters) document and the input schema of the [MCP](https://spider-gazelle.net/mcp/index.md) tool. ## Where values come from Each argument is looked up by its name: 1. **Path:** a `:name`, `?:name` or `*:name` segment in the route. 1. **Query string:** `?name=value`. 1. **Form data:** fields of an `application/x-www-form-urlencoded` or `multipart/form-data` request body. Path parameters take precedence over query parameters, which take precedence over form fields. If a name appears more than once, the first value is used. Two kinds of argument are read from elsewhere: - an argument named in the route's `body:` option is parsed from the [request body](#request-bodies); - an argument annotated with `@[AC::Param::Info(header: "...")]` is read from a [request header](#header-parameters). ## Required and optional Whether a parameter is required comes from its type and default, never from an annotation: | Argument | Required | When missing | | ---------------------- | -------- | --------------------------------------- | | `limit : Int32` | yes | raises `AC::Route::Param::MissingError` | | `limit : Int32 = 20` | no | `20` | | `limit : Int32? = nil` | no | `nil` | | `limit : Int32?` | no | `nil` | A value that's present but can't be converted raises `AC::Route::Param::ValueError`. For a nilable argument without strict parsing, an unparsable value becomes `nil` instead. Both errors carry `parameter` (the name) and `restriction` (the expected type). Spider-Gazelle doesn't turn them into responses for you: without an [exception handler](https://spider-gazelle.net/guides/errors/#handling-parameter-errors) they become a `500 Internal Server Error`. The [application template](https://spider-gazelle.net/getting_started/index.md) includes handlers that respond with `422` and `400`. Defaults must match the type exactly Crystal doesn't widen integer literals in arguments, so an `Int64` argument needs an `Int64` default: `id : Int64 = 0_i64`, not `id : Int64 = 0`. The same applies to `UInt32` (`0_u32`), `Float32` (`0.0_f32`) and so on. ## Supported types | Type | Accepts | Notes | | ---------------------------------------- | ------------------------------------- | -------------------------------------------------------------------------------------- | | `String` | anything | | | `Char` | the first character | | | `Bool` | `true`, case-insensitive | Any other value is `false`, never an error. | | `Int8` to `Int128`, `UInt8` to `UInt128` | integers | | | `Float32`, `Float64` | decimals | | | `BigInt`, `BigDecimal`, `BigFloat` | big numbers | | | `Time` | ISO 8601, e.g. `2024-01-02T03:04:05Z` | Use `config:` for other formats. | | `UUID` | UUID strings | | | any `Enum` | a member name or its integer value | Names ignore case and underscores: `dark_green`, `DarkGreen` and `DARKGREEN` all work. | | a union, e.g. \`Int64 | String\` | any member type | | no type | anything | Treated as `String?`. | Any other type needs a [custom converter](#custom-converters). That includes arrays: `Array(String)` has no built-in converter. Union members are tried in Crystal's internal union order, which isn't necessarily the order you wrote. `String | Int64` tries `Int64` first, so `"12"` becomes an `Int64`, and `Float64 | Int64` always produces a `Float64`. Avoid unions whose members can parse the same value. ``` class Lookup < AC::Base base "/lookup" enum Colour Red Green Blue end # GET /lookup/42 # => "Int64" # GET /lookup/alice # => "String" @[AC::Route::GET("/:id")] def show(id : Int64 | String) : String id.class.to_s end # GET /lookup/paint/green # => "Green" # GET /lookup/paint/2 # => "Blue" @[AC::Route::GET("/paint/:colour")] def paint(colour : Colour) : String colour.to_s end end ``` ## Converter options The `config:` route option passes options to a parameter's converter, keyed by argument name: ``` class Options < AC::Base base "/options" # GET /options/FF?since=2024-01-02%20%2B10:00 # => [255, "2024-01-02 00:00:00 +10:00"] @[AC::Route::GET("/:id", config: { id: {base: 16}, since: {format: "%F %:z"}, })] def show(id : Int32, since : Time? = nil) : Tuple(Int32, String) {id, since.to_s} end end ``` | Type | Option | Default | Effect | | -------------------- | ----------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- | | integers | `base` | `10` | Number base, e.g. `16` for hex. | | | `underscore` | `false` | Allow `1_000`. | | | `prefix` | `false` | Allow `0x`, `0b` and `0o` prefixes. | | | `whitespace` | `true` | Allow surrounding whitespace. | | | `strict` | `true` | Reject trailing characters. | | | `leading_zero_is_octal` | `false` | Treat `017` as octal. | | `Float32`, `Float64` | `whitespace` | `true` | Allow surrounding whitespace. | | | `strict` | `true` | `strict: false` reads `4.5abc` as `4.5`. | | `BigInt` | `base` | `10` | Number base. | | `Bool` | `true_string` | `"true"` | The value that means `true`. | | `Time` | `format` | ISO 8601 | A [`Time::Format`](https://crystal-lang.org/api/latest/Time/Format.html) pattern, parsed as UTC unless it includes a zone. | | `UUID` | `variant`, `version` | any | Require a `UUID::Variant` or `UUID::Version`. | | enums | `from_value` | `false` | Only accept the integer value. | | | `strict` | `false` | Raise `ValueError` for an unparsable value instead of resolving to `nil`. | When `config:` is given, only the first non-nil type of a union is used. The `strict: true` enum option matters for nilable enums. Without it, an unknown value is silently ignored: ``` class Paint < AC::Base base "/paint" enum Colour Red Green end # GET /paint?colour=red # => "Red" # GET /paint # => "" # GET /paint?colour=cyan # => AC::Route::Param::ValueError @[AC::Route::GET("/", config: {colour: {strict: true}})] def index(colour : Colour? = nil) : String colour.to_s end end ``` Options can also be set on the argument with `@[AC::Param::Info(config: {...})]`. If both are present, the route's `config:` wins, so a method with several routes can parse the same argument differently on each: ``` class Hex < AC::Base base "/hex" # GET /hex/255 # => 255 # GET /hex/x/FF # => 255 @[AC::Route::GET("/:id")] @[AC::Route::GET("/x/:id", config: {id: {base: 16}})] def show(id : UInt64) : UInt64 id end end ``` ## Describing parameters with `@[AC::Param::Info]` `@[AC::Param::Info]` annotates a single argument. All of its keys are optional: | Key | Purpose | | -------------- | ------------------------------------------------------------------- | | `description:` | Describes the parameter in OpenAPI and MCP. | | `example:` | An example value, as a string, for OpenAPI and MCP. | | `name:` | The request parameter name, when it differs from the argument name. | | `header:` | Read the value from this request header instead. | | `class:` | A custom converter for this argument. | | `config:` | Options for the converter. | There's no `required:` key. Make an argument optional by giving it a default or a nilable type. `@[AC::Param::Converter]` is the same annotation under another name. Use whichever reads better. ``` class Articles < AC::Base base "/articles" # Lists articles, newest first @[AC::Route::GET("/")] def index( @[AC::Param::Info(description: "filter by tag", example: "crystal")] tag : String? = nil, @[AC::Param::Info(description: "filter by author", example: "jake")] author : String? = nil, @[AC::Param::Info(description: "maximum number of articles", example: "20")] limit : UInt32 = 20_u32, @[AC::Param::Info(description: "number of articles to skip", example: "0")] offset : UInt32 = 0_u32, ) : Array(String) [] of String end end ``` ## Renaming parameters Request parameters don't always make good Crystal names. Rename one with `name:` on the argument, or with the route's `map:` option (argument name to parameter name): ``` class Pages < AC::Base base "/pages" # GET /pages?perPage=50&q=docs @[AC::Route::GET("/", map: {per_page: :perPage})] def index( per_page : Int32 = 10, @[AC::Param::Info(name: "q")] query : String? = nil, ) : String "#{per_page} results for #{query}" end end ``` The original argument name is no longer read: `?per_page=50` is ignored above. ## Header parameters Set `header:` to read an argument from a request header. Required-ness, defaults and conversion work as they do for other parameters, and the header appears in the OpenAPI document: ``` require "uuid" class Jobs < AC::Base base "/jobs" @[AC::Route::POST("/")] def create( @[AC::Param::Info(header: "X-Request-UUID", description: "an idempotency key", example: "ba714f86-cac6-42c7-8956-bcf5105e1b81")] request_id : UUID, @[AC::Param::Info(header: "X-Priority")] priority : Int32 = 5, ) : String "job #{request_id} at priority #{priority}" end end ``` A missing required header raises `MissingError`, and a value that can't be converted raises `ValueError`, with the header name as the `parameter`. ## Custom converters A converter is any class or struct with a `convert(raw : String)` method. Its `initialize` arguments are the options that `config:` can pass. Return `nil` when the value can't be converted, and Spider-Gazelle raises the usual parameter errors. Attach a converter to an argument with `class:`, or to a route with `converters:`: ``` # Parses comma separated lists, e.g. "red, green,blue" struct ConvertList def initialize(@separator : String = ",") end def convert(raw : String) : Array(String) raw.split(@separator).map(&.strip).reject(&.empty?) end end class Tags < AC::Base base "/tags" # GET /tags?tags=red,green # => ["red","green"] @[AC::Route::GET("/")] def index( @[AC::Param::Info(class: ConvertList)] tags : Array(String) = [] of String, ) : Array(String) tags end # GET /tags/pipes?tags=red|green # => ["red","green"] @[AC::Route::GET("/pipes", converters: {tags: ConvertList}, config: {tags: {separator: "|"}})] def pipes(tags : Array(String)) : Array(String) tags end end ``` To use a converter for every argument of a type without naming it each time, define it as `AC::Route::Param::Convert` followed by the type name. Spider-Gazelle looks this up for any type it doesn't recognise: ``` record Commit, branch : String, sha : String # used automatically for any `Commit` argument struct AC::Route::Param::ConvertCommit # e.g. "main#742887" def convert(raw : String) : Commit? parts = raw.split('#', 2) Commit.new(parts[0], parts[1]) if parts.size == 2 end end class Builds < AC::Base base "/builds" # GET /builds/main%23742887 # => "742887 on main" @[AC::Route::GET("/:commit")] def show(commit : Commit) : String "#{commit.sha} on #{commit.branch}" end end ``` Explicit converters take precedence over the built-in ones, so they can also replace the parsing of a standard type such as `Bool`. ## Request bodies Name an argument in the route's `body:` option to parse the request body into it. The argument's type must be deserialisable by the parser for the request's `Content-Type`. JSON is supported out of the box, using [`JSON::Serializable`](https://crystal-lang.org/api/latest/JSON/Serializable.html): ``` require "action-controller" struct NewUser include JSON::Serializable getter name : String getter email : String end class Users < AC::Base base "/users" # Registers a user @[AC::Route::POST("/", body: :user, status_code: HTTP::Status::CREATED)] def create(user : NewUser) : String "welcome #{user.name}" end end # curl -X POST http://localhost:3000/users \ # -H "Content-Type: application/json" \ # -d '{"name": "Jim", "email": "jim@example.com"}' # => "welcome Jim" ``` How the body is parsed: - The parser is chosen by the request's `Content-Type`. Requests without one use the default parser (`application/json`). - An unsupported `Content-Type` raises `AC::Route::UnsupportedMediaType`, whose `accepts` lists the supported types. - A `String` body argument also accepts `text/plain`, and receives the raw body. - A body that doesn't match the type raises the parser's error, e.g. `JSON::SerializableError` (a `JSON::ParseException`). Handle it to return a `400`, see [errors](https://spider-gazelle.net/guides/errors/#recommended-handlers). - A request without a body uses the argument's default, if it has one. - The body type becomes the OpenAPI request body schema, and the MCP tool's `body` input. The body argument can sit alongside path, query and header arguments: ``` @[AC::Route::PATCH("/:id", body: :changes)] def update(id : Int64, changes : NewUser, notify : Bool = false) : String "updated #{id}" end ``` ### Adding parsers `add_parser` registers a parser for another content type. The block receives the argument's type, the body `IO` and the request. `default_parser` sets the parser used when the request has no `Content-Type`: ``` require "yaml" abstract class Application < AC::Base add_parser("application/yaml") do |klass, body_io, request| klass.from_yaml(body_io) end end ``` Any route's body can arrive in any registered format, so every body type in the application must then support YAML too, e.g. by including `YAML::Serializable`, or the application won't compile. Parsers and responders are application-wide `add_parser`, `default_parser`, `add_responder` and `default_responder` register globally, whichever controller they appear in. Declare them once, in your base class. ## Form data and file uploads Fields of `application/x-www-form-urlencoded` and `multipart/form-data` bodies are parameters, just like query parameters: ``` class Signups < AC::Base base "/signups" # curl -X POST http://localhost:3000/signups -d "name=Jim&age=42" @[AC::Route::POST("/")] def create(name : String, age : Int32? = nil) : String "#{name} (#{age})" end end ``` Files in a multipart body are available from `files`, a `Hash(String, Array(FileUpload))?` keyed by field name: ``` class Uploads < AC::Base base "/uploads" @[AC::Route::POST("/")] def create : Array(String) uploads = files.try(&.["attachment"]?) || return [] of String uploads.map do |upload| "#{upload.filename}: #{File.size(upload.file.path)} bytes" end end end ``` Each `AC::BodyParser::FileUpload` has `name`, `filename`, `headers`, `size` (if the client sent it) and `file`, the temporary file holding the upload. `file` is already closed, so open it by path to read it: `File.open(upload.file.path)`. The temporary files are deleted once the request completes, so copy anything you want to keep. ## Reading the request directly Typed arguments cover most needs, but the raw request is always available in a controller: | Method | Returns | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `request` | The [`HTTP::Request`](https://crystal-lang.org/api/latest/HTTP/Request.html), including `request.headers` and `request.body`. | | `params` | Path, query and form parameters combined, as `URI::Params`. | | `route_params` | Path parameters only. | | `query_params` | Query parameters only. | | `form_data` | Form fields only, or `nil`. | | `files` | Uploaded files, or `nil`. | | `request_content_type` | The request's media type, e.g. `"application/json"`. | | `accepts_formats` | The media types in the `Accept` header. | | `client_ip` | The client's IP, using `X-Forwarded-For`, `X-Real-IP` or `Forwarded` when present. | | `request_protocol` | `:https` or `:http`, based on `X-Forwarded-Proto` or `Forwarded`. | | `cookies`, `session` | See [sessions](https://spider-gazelle.net/guides/sessions/index.md). | | `action_name` | The name of the route method being run, as a `Symbol`. | `request.body` can only be read once, so don't read it in a route that also uses `body:` or form parameters. ## See also - [Routing](https://spider-gazelle.net/guides/routing/index.md): paths and route options - [Responses](https://spider-gazelle.net/guides/responses/index.md): what happens to the return value - [Errors](https://spider-gazelle.net/guides/errors/index.md): responding to parameter errors - [Filters](https://spider-gazelle.net/guides/filters/#typed-parameters): typed parameters in filters - [OpenAPI: describing routes](https://spider-gazelle.net/openapi/descriptions/index.md) - [MCP](https://spider-gazelle.net/mcp/index.md) # Responses A route method's return value is the response body. Spider-Gazelle picks a format from the request's `Accept` header, serialises the value and sets the status code from the route annotation. This page covers status codes, content negotiation, responders, `render` and friends, headers, files, CORS and forcing TLS. ``` require "action-controller" struct Comment include JSON::Serializable getter id : Int64 getter body : String def initialize(@id, @body) end end class Comments < AC::Base base "/comments" # Returns a comment @[AC::Route::GET("/:id")] def show(id : Int64) : Comment Comment.new(id, "great article") end # Creates a comment @[AC::Route::POST("/", status_code: HTTP::Status::CREATED)] def create(body : String) : Comment Comment.new(1_i64, body) end end require "action-controller/server" AC::Server.new.run # GET /comments/7 # => 200 {"id":7,"body":"great article"} # POST /comments?body=hi # => 201 {"id":1,"body":"hi"} ``` The return type is optional, but declare it. It documents the route, it's checked by the compiler, and it's the response schema in the [OpenAPI](https://spider-gazelle.net/openapi/index.md) document and the [MCP](https://spider-gazelle.net/mcp/index.md) tool's result. ## Return values | Returned value | Response | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | | an object | Serialised by the selected [responder](#responders). JSON by default, using `to_json`. | | a `String` | Also serialised, so the default JSON responder sends `"text"` with quotes. Use a [`text/plain` responder](#fixed-content-types) for plain text. | | `nil` | No body. The status code and headers are still sent. | | anything, for a `HEAD` request | No body. | If the method has already sent a response with [`render`, `head` or `redirect_to`](#render-head-and-redirect_to), the return value is ignored. ## Status codes Responses are `200 OK` unless you say otherwise. Set a different status for a route with `status_code:`: ``` @[AC::Route::POST("/", status_code: HTTP::Status::CREATED)] def create(body : String) : Comment Comment.new(1_i64, body) end ``` When a route can return different types, map each type to a status with `status:`. Declare the return type as a union so the compiler, and the OpenAPI document, know every possibility: ``` struct Missing include JSON::Serializable getter error : String def initialize(@error) end end class Lookups < AC::Base base "/lookups" # Finds a comment @[AC::Route::GET("/:id", status: {Missing => HTTP::Status::NOT_FOUND})] def show(id : Int64) : Comment | Missing if id == 1 Comment.new(id, "found it") else Missing.new("no comment #{id}") end end end ``` The returned value's type selects the status, and types not in the map use `status_code:` (default `200`). Both statuses appear as responses in the OpenAPI operation. Status values can be an `HTTP::Status` or an integer. For errors raised from deeper in your code, [exception handlers](https://spider-gazelle.net/guides/errors/index.md) are usually a better fit than return types. ## Content negotiation The route picks a responder before your method runs: 1. With no `Accept` header, or `Accept: */*`, it uses the default responder (`application/json`). 1. Otherwise it uses the first type listed in `Accept` that has a responder. Quality values (`;q=0.8`) are ignored, so list order decides. 1. If none match and there's no `*/*`, it raises `AC::Route::NotAcceptable`, and your method isn't called. Its `accepts` lists the available types. Handle it to return a `406`, see [errors](https://spider-gazelle.net/guides/errors/#recommended-handlers). The response `Content-Type` is set to the selected type, unless your method has already set one. ## Responders `add_responder` registers a responder for a content type. The block writes `result` to `io`, the response: ``` require "yaml" abstract class Application < AC::Base add_responder("application/yaml") { |io, result| result.to_yaml(io) } add_responder("text/plain") { |io, result| result.to_s(io) } # used when the client doesn't ask for a format default_responder "application/json" end ``` - The block can also take the controller name and the action name as symbols: `|io, result, controller, action|`. - It runs in the context of the controller instance, so `request`, `response` and your helper methods are available. - `default_responder` sets the type used when the client accepts anything. It must already have a responder. - Responders are registered application-wide, whichever controller declares them. Declare them once, in your base class. - Any route can be asked for any registered format, so every route's return type must support every responder. For YAML, include `YAML::Serializable` in your response types, or the application won't compile. ## Fixed content types `content_type:` fixes a route's response type. The `Accept` header is ignored and the response always has this `Content-Type`: ``` abstract class Application < AC::Base add_responder("text/plain") { |io, result| result.to_s(io) } end class Health < Application base "/health" # GET /health # => ok @[AC::Route::GET("/", content_type: "text/plain")] def index : String "ok" end end ``` Register a responder for the content type `content_type:` sets the header, but the body is still written by the responder registered for that type. If there isn't one, the default (JSON) responder writes it, so the route above would send `"ok"` in quotes with a `text/plain` header. Exception handlers accept `content_type:` too. ## `render`, `head` and `redirect_to` These macros send a response immediately and `return` from the method. They work in annotated routes, [filters](https://spider-gazelle.net/guides/filters/index.md) and exception handlers. ``` class Accounts < AC::Base base "/accounts" @[AC::Route::GET("/:id")] def show(id : Int64) : String? head :not_found if id == 0 redirect_to "/accounts/1", status: :moved_permanently if id == 99 render :accepted, text: "account #{id}" if id == 2 "account #{id}" end @[AC::Route::DELETE("/:id")] def destroy(id : Int64) head :no_content end end ``` Return types `head` and `redirect_to` return `nil`, and `render` returns the value it rendered. A route that uses them needs a return type that allows those values, such as `String?`, or no return type at all. ### `render` `render` takes an optional status and one body option: | Option | Content type | Body | | ----------------------- | -------------------------- | --------------------------------------------------------------------- | | `json:` | `application/json` | `to_json`, or the string as-is | | `yaml:` | `text/yaml` | `to_yaml`, or the string as-is | | `xml:` | `application/xml` | `to_s` | | `html:` | `text/html` | `to_s` | | `text:` | `text/plain` | `to_s` | | `binary:` | `application/octet-stream` | `to_s` | | `template:`, `partial:` | `text/html` | a [Kilt](https://github.com/jeromegn/kilt) template from `src/views/` | ``` render json: {status: "ok"} render :created, json: comment render HTTP::Status::ACCEPTED, text: "queued" render :forbidden # status only, no body ``` The status can be a symbol, an `HTTP::Status` or an integer. Symbols are snake case status names: `:ok`, `:created`, `:no_content`, `:bad_request`, `:unauthorized`, `:forbidden`, `:not_found`, `:unprocessable_entity` and so on. A `Content-Type` already set on the response is kept. ### `head` `head status` sends a status with no body, e.g. `head :no_content`. ### `redirect_to` `redirect_to path, status: :found` sets the `Location` header. The status defaults to `302 Found`. Symbols must be redirect statuses: `:moved_permanently`, `:found`, `:see_other`, `:not_modified`, `:temporary_redirect` or `:permanent_redirect`. Build paths with the [route helpers](https://spider-gazelle.net/guides/routing/#building-paths-to-routes). ### `respond_with` `respond_with` chooses between several formats in the action itself. It's intended for [macro DSL](https://spider-gazelle.net/guides/routing/#macro-dsl) routes, because annotated routes have already negotiated the format before the method runs: ``` get "/:id", :show do comment = Comment.new(params["id"].to_i64, "great article") respond_with do json comment text comment.body end end ``` It uses the first option that the `Accept` header allows, or the first option listed if there's no `Accept` header. If nothing matches, it responds `406 Not Acceptable`. ## Headers `response` is the [`HTTP::Server::Response`](https://crystal-lang.org/api/latest/HTTP/Server/Response.html). Set headers on it before the body is written: ``` @[AC::Route::GET("/report")] def report : Array(String) response.headers["Cache-Control"] = "no-store" response.headers["X-Report-Version"] = "2" ["row 1", "row 2"] end ``` Set `response.content_type = "..."` to change the content type. The body is still written by the negotiated responder. To set headers on every response, use a [before filter](https://spider-gazelle.net/guides/filters/index.md). ## Conditional requests and caching `stale?` sets the `ETag` and `Last-Modified` headers and checks them against the request's `If-None-Match` and `If-Modified-Since`. When the client's copy is current, it responds `304 Not Modified` and returns `false`: ``` @[AC::Route::GET("/:id")] def show(id : Int64) : Comment? comment = Comment.new(id, "great article") updated_at = Time.utc(2024, 1, 1) if stale?(last_modified: updated_at, etag: %("comment-#{id}-v1")) comment end end ``` `public: true` also marks the response `Cache-Control: public`. Pass either validator on its own, or both. When a client sends both `If-None-Match` and `If-Modified-Since`, both must match for a `304`: ``` @[AC::Route::GET("/:id")] def show(id : Int64) : Comment? comment = Comment.find!(id) comment if stale?(etag: %("comment-#{id}-#{comment.version}")) end ``` ## Files and streams To send a file or other large data, set the headers, mark the response as rendered and write to `response` directly. The data is streamed rather than held in memory: ``` @[AC::Route::GET("/export.csv")] def export : Nil response.content_type = "text/csv" response.headers["Content-Disposition"] = %(attachment; filename="export.csv") # tells Spider-Gazelle the response has been handled @__render_called__ = true File.open("/data/export.csv") do |file| IO.copy(file, response) end end ``` Set `@__render_called__ = true` before writing. Otherwise Spider-Gazelle tries to set the status and content type after your data has been sent. For data already in memory as a `String`, `render binary: data` is simpler. ## CORS Browsers only let pages call an API on another origin if the response carries [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) headers. Add them in a before filter, and answer the browser's `OPTIONS` preflight requests: ``` abstract class Application < AC::Base @[AC::Route::Filter(:before_action)] def set_cors_headers response.headers["Access-Control-Allow-Origin"] = "https://app.example.com" response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS" response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" end end # Answers CORS preflight requests for every path @[AC::MCP(hide: true)] class Preflight < Application base "/" @[AC::Route::OPTIONS("/*:path")] def preflight head :no_content end end ``` The preflight route is hidden from [MCP](https://spider-gazelle.net/mcp/index.md), as it isn't useful to an agent. Browsers don't send credentials with a preflight request, so if your base class has an authentication filter, skip it here with [`skip_action`](https://spider-gazelle.net/guides/filters/#skipping-inherited-filters). Use `"*"` as the allowed origin only for public APIs that don't use cookies. ## Forcing TLS `force_tls` makes routes reject plain HTTP. A plain HTTP request is redirected (`302`) to the same URL over `https://`, and a WebSocket request gets `412 Precondition Failed`. ``` class Payments < AC::Base base "/payments" # only these routes force_tls only: [:create, :destroy] end class Admin < AC::Base base "/admin" # every route except the health check force_tls except: [:health] end ``` Without `only:` or `except:`, every route in the controller requires TLS: ``` abstract class Application < AC::Base force_tls end ``` How a request's protocol is determined: - **Behind a proxy:** the `X-Forwarded-Proto` or `Forwarded` header describes the client's connection, so it takes precedence. Make sure your TLS-terminating proxy or load balancer sets one of them. - **Serving TLS yourself:** a connection to a port that `ActionController::Server` bound with TLS (`Server.new(ssl_context, ...)`) is HTTPS. `force_ssl` is an alias. Note The no-options form and the TLS port detection need action-controller 8.3.3 or later. ## See also - [Routing](https://spider-gazelle.net/guides/routing/index.md): route options - [Errors](https://spider-gazelle.net/guides/errors/index.md): responses for exceptions - [Filters](https://spider-gazelle.net/guides/filters/index.md): setting headers for every route - [Sessions](https://spider-gazelle.net/guides/sessions/index.md): cookies and sessions - [OpenAPI: describing routes](https://spider-gazelle.net/openapi/descriptions/#responses) # Filters Filters are controller methods that run before, around or after your routes. Use them for work shared by many routes: authentication, loading records, setting headers, wrapping actions in database transactions, and auditing. ``` require "action-controller" abstract class Application < AC::Base getter! current_user : String # Runs before every route in every controller that inherits from Application @[AC::Route::Filter(:before_action)] def authenticate( @[AC::Param::Info(header: "Authorization", description: "a bearer token")] auth : String? = nil, ) render :unauthorized, text: "sign in required" unless auth == "Bearer secret" @current_user = "alice" end end class Profile < Application base "/profile" @[AC::Route::GET("/")] def show : String "signed in as #{current_user}" end end require "action-controller/server" AC::Server.new.run # GET /profile # => 401 sign in required # GET /profile (Authorization: Bearer secret) # => "signed in as alice" ``` ## Filter types | Filter | Runs | Typical uses | | ---------------- | --------------------------------------------------------- | ------------------------------------------------------- | | `:before_action` | before the action | authentication, authorisation, loading records, headers | | `:around_action` | wraps the before filters and the action, and must `yield` | transactions, timing, context that needs cleaning up | | `:after_action` | after the action has rendered | logging, auditing, metrics | A request runs in this order: 1. around filters, outermost first, each `yield`ing to the next; 1. before filters; 1. the action; 1. after filters. Within each type, filters inherited from parent classes run first, then the controller's own, in the order they're defined. ## Declaring filters Annotate a method with `@[AC::Route::Filter(type)]`: ``` abstract class Application < AC::Base @[AC::Route::Filter(:before_action)] def set_request_headers response.headers["X-Frame-Options"] = "DENY" end end ``` Or register an existing method with the `before_action`, `around_action` and `after_action` macros: ``` abstract class Application < AC::Base before_action :set_request_headers private def set_request_headers response.headers["X-Frame-Options"] = "DENY" end end ``` The two styles behave the same. The annotation form is preferred because filter methods can then take [typed parameters](#typed-parameters). Methods registered with the macros can be private and can't take parameters. ## Choosing routes with `only` and `except` By default a filter applies to every route in the controller and its subclasses. Limit it by route method name, with a symbol or an array of symbols: ``` class Comments < Application base "/comments" @[AC::Route::Filter(:before_action, only: [:update, :destroy])] def check_owner # ... end @[AC::Route::Filter(:after_action, except: :index)] def audit Log.info { "#{action_name} by #{current_user}" } end @[AC::Route::GET("/")] def index : Array(String) [] of String end @[AC::Route::PATCH("/:id")] def update(id : Int64) : String "updated #{id}" end @[AC::Route::DELETE("/:id")] def destroy(id : Int64) head :no_content end end ``` The macros take the same options: `before_action :check_owner, only: [:update, :destroy]`. ## Stopping a request A before filter stops the request by sending a response with [`render`, `head` or `redirect_to`](https://spider-gazelle.net/guides/responses/#render-head-and-redirect_to), or by raising an exception. The remaining before filters and the action don't run: ``` @[AC::Route::Filter(:before_action)] def require_admin head :forbidden unless current_user == "admin" end ``` Raising works well with [exception handlers](https://spider-gazelle.net/guides/errors/index.md), because the same error then gets the same response wherever it's raised: ``` @[AC::Route::Filter(:before_action)] def require_admin raise AC::Error::Forbidden.new("admins only") unless current_user == "admin" end ``` After filters still run when a before filter has rendered, so they can log rejected requests. Check `render_called?` if you need to tell the difference. ## Typed parameters Annotation filters can take arguments, parsed exactly like [route parameters](https://spider-gazelle.net/guides/parameters/index.md): from the path, query string, form data or headers, with type conversion, defaults and `@[AC::Param::Info]`. This makes filters a good place to load the record a route works on: ``` struct Article include JSON::Serializable getter id : Int64 getter title : String def initialize(@id, @title) end end class Articles < AC::Base base "/articles" getter! article : Article @[AC::Route::Filter(:before_action, only: [:show, :update])] def find_article(id : Int64) @article = Article.new(id, "Article #{id}") end @[AC::Route::GET("/:id")] def show : Article article end @[AC::Route::PATCH("/:id")] def update(title : String) : Article Article.new(article.id, title) end end ``` - A missing or invalid filter parameter raises the usual [parameter errors](https://spider-gazelle.net/guides/errors/#handling-parameter-errors). - Make a parameter nilable, e.g. `id : Int64?`, when the filter applies to routes that don't all have it. - Filter parameters are added to the OpenAPI parameters of every route the filter applies to. See [parameters from filters](https://spider-gazelle.net/openapi/descriptions/#parameters-from-filters). `getter!` creates an `article` method that raises if `@article` is `nil`, and an `article?` method that returns `nil` instead. ## Around filters An around filter must accept a block and `yield` to run the rest of the request: ``` abstract class Application < AC::Base @[AC::Route::Filter(:around_action, only: [:create, :update, :destroy])] def wrap_in_transaction(&) Database.transaction { yield } end end ``` Code after `yield` runs once the before filters and the action have finished. Use `ensure` for cleanup that must happen even if the action raises: ``` @[AC::Route::Filter(:around_action)] def instrument(&) started = Time.instant yield ensure Log.info { "#{action_name} took #{Time.instant - started}" } if started end ``` Keep around filters thin Crystal inlines a method that `yield`s into every route it wraps, so a large around filter is duplicated once per route in your binary. Keep the body small and move the work into ordinary methods. ## After filters After filters run once the action has produced its response. They can read `response.status_code` and `action_name`, but the body has already been written, so don't use them to change the response. After filters don't run if the action raises an exception. Put cleanup that must always happen in an around filter's `ensure`. ## Skipping inherited filters `skip_action` turns off an inherited filter, for all of a controller's routes or for some of them: ``` class Sessions < Application base "/sessions" # the sign in page can't require a signed in user skip_action :authenticate, only: [:sign_in] @[AC::Route::GET("/new")] def sign_in : String "sign in here" end end ``` It takes the filter's method name, and works for annotation and macro filters alike. Without `only:` or `except:` the filter is skipped for every route in the controller. ## See also - [Routing](https://spider-gazelle.net/guides/routing/index.md) - [Parameters](https://spider-gazelle.net/guides/parameters/index.md) - [Responses](https://spider-gazelle.net/guides/responses/index.md): `render`, `head` and `redirect_to` - [Errors](https://spider-gazelle.net/guides/errors/index.md): handling exceptions raised by filters - [Sessions](https://spider-gazelle.net/guides/sessions/index.md): reading the signed in user from a session - [Logging](https://spider-gazelle.net/guides/logging/index.md): request IDs and log context # Errors Exception handlers turn exceptions into responses. Raise an error anywhere in a route, filter or the code they call, and a handler declared on the controller, or inherited from a parent, decides the status code and body. This page covers declaring handlers, the errors Spider-Gazelle raises, and what happens to exceptions nobody handles. ``` require "action-controller" class Divide < AC::Base base "/divide" # Divides one number by another @[AC::Route::GET("/:num1/:num2")] def divide(num1 : Int32, num2 : Int32) : Int32 num1 // num2 end # Responds 400 when dividing by zero @[AC::Route::Exception(DivisionByZeroError, status_code: HTTP::Status::BAD_REQUEST)] def division_by_zero(error) : NamedTuple(error: String?) {error: error.message} end end require "action-controller/server" AC::Server.new.run # GET /divide/10/2 # => 200 5 # GET /divide/10/0 # => 400 {"error":"Division by 0"} ``` The handler's status code and return type are added to the responses of every route it covers in the [OpenAPI](https://spider-gazelle.net/openapi/index.md) document. ## Exception handlers Annotate a method with `@[AC::Route::Exception(ErrorClass)]`. It handles that class and its subclasses, raised from any route or filter in the controller and its subclasses. - The first argument is the exception. It's typed as the class in the annotation. - The return value is rendered like a route's: serialised by the negotiated [responder](https://spider-gazelle.net/guides/responses/#responders). - If the client's `Accept` header can't be satisfied, the default responder is used, so errors always get a response. | Option | Purpose | | --------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `status_code:` | The response status, `200 OK` if omitted, so always set it. | | `content_type:` | Always respond with this content type, see [fixed content types](https://spider-gazelle.net/guides/responses/#fixed-content-types). | One method can handle several exception classes, each with its own status: ``` abstract class Application < AC::Base @[AC::Route::Exception(AC::Error::NotFound, status_code: HTTP::Status::NOT_FOUND)] @[AC::Route::Exception(AC::Error::Conflict, status_code: HTTP::Status::CONFLICT)] def resource_error(error) : AC::Error::CommonResponse AC::Error::CommonResponse.new(error, backtrace: false) end end ``` Handlers can take extra arguments, parsed like [route parameters](https://spider-gazelle.net/guides/parameters/index.md). Make them nilable, as the failing request may not include them: ``` @[AC::Route::Exception(AC::Error::NotFound, status_code: HTTP::Status::NOT_FOUND)] def not_found(error, id : Int64?) : NamedTuple(error: String?, id: Int64?) {error: error.message, id: id} end ``` Handlers can also call [`render`, `head` or `redirect_to`](https://spider-gazelle.net/guides/responses/#render-head-and-redirect_to) to take full control of the response. ### Generic exceptions Exceptions can be generic, for example an error class parameterised by its status code. Handle one instance, or name the generic itself to handle every instance: ``` class ApiError(Code) < Exception def code : Int32 Code end end abstract class Application < AC::Base # handles ApiError(404) only @[AC::Route::Exception(ApiError(404), status_code: HTTP::Status::NOT_FOUND)] def api_not_found(error) : AC::Error::CommonResponse AC::Error::CommonResponse.new(error, backtrace: false) end # handles every other ApiError, such as ApiError(400) and ApiError(409) @[AC::Route::Exception(ApiError, status_code: HTTP::Status::BAD_REQUEST)] def api_error(error) : AC::Error::CommonResponse AC::Error::CommonResponse.new(error, backtrace: false) end end ``` The status code in the annotation is fixed. To respond with each error's own code, call `render status: error.code, json: ...` in the handler. ## `rescue_from` The `rescue_from` macro is an alternative that doesn't use annotations. Pass a method name, or a block: ``` class Books < AC::Base base "/books" rescue_from KeyError, :missing_key rescue_from IndexError do |error| render :not_found, json: {error: error.message} end def missing_key(error) render :not_found, json: {error: error.message} end end ``` A `rescue_from` handler must send its response with `render` or `head`: its return value isn't rendered, and it doesn't add a response to the OpenAPI document. Prefer the annotation. ## Built-in errors Spider-Gazelle raises these while handling a request: | Error | Raised when | Suggested status | | --------------------------------- | --------------------------------------------------- | ---------------- | | `AC::Route::Param::MissingError` | a required parameter or header is missing | `422` | | `AC::Route::Param::ValueError` | a parameter can't be converted to its type | `400` | | `AC::Route::NotAcceptable` | no responder matches the `Accept` header | `406` | | `AC::Route::UnsupportedMediaType` | no parser matches the request body's `Content-Type` | `415` | Both parameter errors inherit from `AC::Route::Param::Error`, which has `parameter` (the name) and `restriction` (the expected type). Both media type errors inherit from `AC::Route::Error`, which has `accepts`, the list of supported types. These are provided for your own code, with no built-in handlers: | Error | Typical status | | ------------------------- | -------------- | | `AC::Error::Unauthorized` | `401` | | `AC::Error::Forbidden` | `403` | | `AC::Error::NotFound` | `404` | | `AC::Error::Conflict` | `409` | ``` raise AC::Error::NotFound.new("no article with id #{id}") ``` `AC::Error` also provides response structs to return from handlers. All of them serialise to JSON and YAML: | Struct | Fields | | ------------------------------ | --------------------------------------------------------------- | | `AC::Error::CommonResponse` | `error`, and `backtrace` unless created with `backtrace: false` | | `AC::Error::ParameterResponse` | `error`, `parameter`, `restriction` | | `AC::Error::ContentResponse` | `error`, `accepts` | ## Handling parameter errors Spider-Gazelle doesn't turn the built-in errors into responses for you. Without a handler, a missing parameter is a `500 Internal Server Error`. Handle them in your base class: ``` abstract class Application < AC::Base # a parameter is missing or can't be parsed @[AC::Route::Exception(AC::Route::Param::MissingError, status_code: HTTP::Status::UNPROCESSABLE_ENTITY)] @[AC::Route::Exception(AC::Route::Param::ValueError, status_code: HTTP::Status::BAD_REQUEST)] def invalid_param(error) : AC::Error::ParameterResponse AC::Error::ParameterResponse.new( error: error.message.as(String), parameter: error.parameter, restriction: error.restriction ) end end # GET /search/books?q=crystal&limit=many # => 400 {"error":"invalid parameter value for 'limit'","parameter":"limit","restriction":"Int32"} ``` ## Recommended handlers The [application template](https://spider-gazelle.net/getting_started/index.md) starts with handlers for the built-in errors. A complete base class, adding malformed JSON bodies and the `AC::Error` classes, looks like this: ``` require "action-controller" abstract class Application < AC::Base # no acceptable response format, or an unsupported request body format @[AC::Route::Exception(AC::Route::NotAcceptable, status_code: HTTP::Status::NOT_ACCEPTABLE)] @[AC::Route::Exception(AC::Route::UnsupportedMediaType, status_code: HTTP::Status::UNSUPPORTED_MEDIA_TYPE)] def bad_media_type(error) : AC::Error::ContentResponse AC::Error::ContentResponse.new(error: error.message.as(String), accepts: error.accepts) end # a parameter is missing or can't be parsed @[AC::Route::Exception(AC::Route::Param::MissingError, status_code: HTTP::Status::UNPROCESSABLE_ENTITY)] @[AC::Route::Exception(AC::Route::Param::ValueError, status_code: HTTP::Status::BAD_REQUEST)] def invalid_param(error) : AC::Error::ParameterResponse AC::Error::ParameterResponse.new(error: error.message.as(String), parameter: error.parameter, restriction: error.restriction) end # the request body isn't valid JSON, or doesn't match the expected type @[AC::Route::Exception(JSON::ParseException, status_code: HTTP::Status::BAD_REQUEST)] def invalid_json(error) : AC::Error::CommonResponse AC::Error::CommonResponse.new(error, backtrace: false) end @[AC::Route::Exception(AC::Error::Unauthorized, status_code: HTTP::Status::UNAUTHORIZED)] @[AC::Route::Exception(AC::Error::Forbidden, status_code: HTTP::Status::FORBIDDEN)] @[AC::Route::Exception(AC::Error::NotFound, status_code: HTTP::Status::NOT_FOUND)] @[AC::Route::Exception(AC::Error::Conflict, status_code: HTTP::Status::CONFLICT)] def app_error(error) : AC::Error::CommonResponse AC::Error::CommonResponse.new(error, backtrace: false) end end ``` ## Inheritance and order Handlers are inherited, so declare common ones once in your base class. Two rules follow from how handlers are combined: - **A parent's handler wins.** If a parent and a subclass both handle the same exception class, the parent's handler is used. - **The first matching handler wins.** Handlers are checked in order, parent classes first. A parent handler for a broad class such as `Exception` catches everything, including errors a subclass has a more specific handler for. So keep broad handlers out of your base class, or put them in the leaf controllers that need them. For a catch-all `500` response, rely on the [error handler](#unhandled-exceptions) instead. If a response has already been sent when an exception is raised, for example from an after filter, the handler can't send another one, and the exception continues as if it was unhandled. ## Unhandled exceptions An exception without a handler propagates out of the controller. Add `AC::ErrorHandler` to the server's handlers to turn it into a response. The [application template](https://spider-gazelle.net/getting_started/index.md) does this in `src/config.cr`: ``` # App.running_in_production? is defined in the template's src/constants.cr ActionController::Server.before( ActionController::ErrorHandler.new(App.running_in_production?, ["X-Request-ID"]), ActionController::LogHandler.new(["password", "bearer_token"]), ) ``` | Mode | Response | | -------------------------------------- | -------------------------------------------------------------------------- | | development, `ErrorHandler.new(false)` | `500` with an HTML page showing the exception and backtrace | | production, `ErrorHandler.new(true)` | `500` with a `{}` body if the client accepts JSON, otherwise an empty body | The second argument lists response headers to keep when the response is reset, such as a request ID set by a filter. Never run the development mode in production, as it exposes your source and backtraces. ## See also - [Responses](https://spider-gazelle.net/guides/responses/index.md): status codes and responders - [Parameters](https://spider-gazelle.net/guides/parameters/index.md): the source of parameter errors - [Filters](https://spider-gazelle.net/guides/filters/index.md): raising errors to stop a request - [Logging](https://spider-gazelle.net/guides/logging/index.md): how exceptions are logged - [OpenAPI: error responses](https://spider-gazelle.net/openapi/descriptions/#error-responses) # Sessions and cookies A session stores a small amount of data for each user between requests, such as the ID of the logged-in user. Spider-Gazelle keeps the session in an encrypted, signed cookie, so there's no server-side session store to run. This page also covers reading and writing ordinary cookies. ## Example ``` require "action-controller" ActionController::Session.configure do |settings| settings.key = "_my_app_session_" settings.secret = "4f74c0b358d5bab4000dd3c75465dc2c" # use your own, at least 32 bytes end class Sessions < AC::Base base "/session" # Logs the user in @[AC::Route::POST("/")] def create(username : String, password : String) : Nil if username == "steve" && password == "secret" session["user_id"] = 42_i64 session["username"] = username else head :unauthorized end end # Returns the logged-in user @[AC::Route::GET("/")] def show : NamedTuple(user_id: Int64?, username: String?) { user_id: session["user_id"]?.as(Int64?), username: session["username"]?.as(String?), } end # Logs the user out @[AC::Route::DELETE("/")] def destroy : Nil session.clear end end ``` After `POST /session?username=steve&password=secret`, the response sets the `_my_app_session_` cookie. The browser sends it back on later requests, and `GET /session` returns `{"user_id":42,"username":"steve"}`. ## Configuration Configure sessions once at start-up. In the [application template](https://spider-gazelle.net/getting_started/configuration/#sessions) this is in `src/config.cr`, and the key and secret come from the `COOKIE_SESSION_KEY` and `COOKIE_SESSION_SECRET` environment variables. ``` ActionController::Session.configure do |settings| settings.key = "_my_app_session_" settings.secret = ENV["COOKIE_SESSION_SECRET"] settings.secure = true end ``` | Setting | Type | Default | Description | | ----------- | --------- | -------------- | ------------------------------------------------------------------------------------------------------------ | | `key` | `String` | none, required | The name of the session cookie. | | `secret` | `String` | none, required | The secret used to encrypt and sign the cookie. | | `max_age` | `Int32` | about 20 years | Cookie lifetime in seconds, from the time the session was last written. | | `secure` | `Bool` | `false` | Adds the `Secure` attribute, so browsers only send the cookie over HTTPS. | | `encrypted` | `Bool` | `true` | Encrypts and signs the data. `false` only signs it, so the client can read the contents but not change them. | | `path` | `String` | `"/"` | The cookie `Path`. | | `domain` | `String?` | `nil` | The cookie `Domain`. `nil` limits the cookie to the host that set it. | Rules: - `key` and `secret` must be set before a session is used. They have no defaults. - When `encrypted` is `true`, the secret must be **at least 32 bytes**. It's used as an AES-256 key, and a shorter secret raises an `ArgumentError` when a session is written. `openssl rand -hex 16` prints a suitable 32 character secret. - Changing the secret invalidates every existing session. Cookies that fail to decrypt or verify are ignored, so those users get an empty session. - Set `secure` to `true` in production. The template does this when `SG_ENV` is `production`. The session cookie is always `HttpOnly` (JavaScript can't read it) and `SameSite=Lax`. ## Using the session Inside a controller, `session` works like a hash with `String` keys: ``` # user.id is an Int64 session["user_id"] = user.id # write session["user_id"]? # read, nil if missing session["user_id"] # read, raises KeyError if missing session.has_key?("user_id") # check session.delete("user_id") # remove one key session["user_id"] = nil # also removes the key session.clear # remove everything ``` Values must be `String`, `Int64`, `Float64` or `Bool`. Other types don't compile, so convert them first. For example, use `42_i64` or `id.to_i64` rather than an `Int32`, and `uuid.to_s` for a `UUID`. Reading a value returns the union type `String | Int64 | Float64 | Bool`. Use `.as(T)` or a `case` to get the type you stored: ``` if user_id = session["user_id"]?.as(Int64?) @current_user = User.find(user_id) end ``` ### Loading the current user in a filter A common pattern is to load the user in a [filter](https://spider-gazelle.net/guides/filters/index.md) on your base controller: ``` abstract class Application < AC::Base getter! current_user : User @[AC::Route::Filter(:before_action)] def authenticate user_id = session["user_id"]?.as(Int64?) return head :unauthorized unless user_id @current_user = User.find(user_id) end end ``` ## How sessions are stored - **Lazy loading.** The cookie is only decrypted the first time a route calls `session`. Routes that never touch the session pay nothing, so there's no need to turn sessions off. - **Written when modified.** The cookie is only sent back if the session changed. `session.touch` marks it as changed without changing any data, which refreshes the cookie's expiry. - **Written with the response.** The cookie is set when the response is rendered. Changes made after that, for example in an `after_action` filter, aren't saved. - **Clearing.** `session.clear` on an existing session sends an empty cookie that expires immediately, which removes it from the browser. - **Size limit.** An encoded session larger than 4096 bytes raises `ActionController::CookieSizeExceeded`. Encryption adds overhead, so the usable space is closer to 3 KB. Store IDs and look the data up, rather than storing the data itself. - **WebSockets.** [WebSocket routes](https://spider-gazelle.net/guides/websockets/index.md) can read the session, but don't write it, because there's no HTTP response to carry the cookie. Warning The session is a cookie, so the user can delete it or replay an old copy of it. Don't store anything there that must be revoked server side, such as a permission that can be taken away. Store an ID and check it on each request. ### Per-request cookie domain `session.domain` overrides the configured `domain` for the current request. This is useful for apps that serve several domains: ``` @[AC::Route::Filter(:before_action)] def set_session_domain session.domain = request.hostname end ``` ## Cookies For data that isn't part of the session, use cookies directly. - `cookies` returns the cookies the client **sent** with the request. - `response.cookies` holds the cookies to **send** back. Setting a value on `cookies` doesn't send it to the client. Always use `response.cookies` to set or delete a cookie. ``` class Preferences < AC::Base base "/preferences" # Returns the user's theme @[AC::Route::GET("/theme")] def theme : String cookies["theme"]?.try(&.value) || "light" end # Remembers the user's theme for a year @[AC::Route::POST("/theme/:theme")] def set_theme(theme : String) : String response.cookies << HTTP::Cookie.new( "theme", theme, path: "/", max_age: 365.days, http_only: true, samesite: :lax, ) theme end # Forgets the theme @[AC::Route::DELETE("/theme")] def reset_theme : Nil cookie = HTTP::Cookie.new("theme", "", path: "/") cookie.expire response.cookies << cookie end end ``` `cookies["name"]?` returns an [`HTTP::Cookie`](https://crystal-lang.org/api/latest/HTTP/Cookie.html), so call `.value` to get the string. `cookie.expire` clears the value and sets an expiry in the past, which tells the browser to delete it. The `path` (and `domain`) must match the cookie you're deleting. Plain cookies aren't encrypted or signed. Treat their values as untrusted input. ## Testing sessions The [spec client](https://spider-gazelle.net/guides/testing/index.md) doesn't keep cookies between requests. Copy them from the response yourself: ``` client = AC::SpecHelper.client response = client.post("/session/?username=steve&password=secret") headers = HTTP::Headers.new response.cookies.add_request_headers(headers) client.get("/session/", headers: headers).body # => {"user_id":42,"username":"steve"} ``` ## See also - [Filters](https://spider-gazelle.net/guides/filters/index.md), for authentication checks - [Configuration](https://spider-gazelle.net/getting_started/configuration/index.md), for the session environment variables - [WebSockets](https://spider-gazelle.net/guides/websockets/index.md) - [Testing](https://spider-gazelle.net/guides/testing/index.md) # WebSockets A WebSocket keeps a connection open so the server and client can send each other messages at any time. Use one for chat, live dashboards and notifications. In Spider-Gazelle a WebSocket route is a controller method, so it gets route params, filters and authentication like any other route. ## Example A chat server with rooms. Every message sent to a room is broadcast to everyone connected to it. ``` require "action-controller" class ChatRoom < AC::Base base "/chat" ROOMS = Hash(String, Array(HTTP::WebSocket)).new { |hash, key| hash[key] = [] of HTTP::WebSocket } LOCK = Mutex.new # Joins a chat room @[AC::Route::WebSocket("/:room")] def join(socket, room : String) : Nil LOCK.synchronize { ROOMS[room] << socket } socket.on_message do |message| peers = LOCK.synchronize { ROOMS[room].dup } peers.each &.send("#{room}: #{message}") end socket.on_close do LOCK.synchronize do sockets = ROOMS[room] sockets.delete(socket) ROOMS.delete(room) if sockets.empty? end end end end require "action-controller/server" AC::Server.new.run ``` Connect from a browser's developer console: ``` const socket = new WebSocket("ws://localhost:3000/chat/lobby"); socket.onmessage = (event) => console.log(event.data); socket.onopen = () => socket.send("hello"); // logs "lobby: hello" ``` ## How WebSocket routes work Declare the route with `@[AC::Route::WebSocket("/path")]`. The rules: - The **first argument** of the method is the [`HTTP::WebSocket`](https://crystal-lang.org/api/latest/HTTP/WebSocket.html). It has no type restriction. - Any other arguments are parsed exactly like a normal route: path params, query params, type conversion, defaults and `@[AC::Param::Info]`. See [Parameters](https://spider-gazelle.net/guides/parameters/index.md). - The method should register its callbacks and **return**. After it returns, Spider-Gazelle runs the socket's read loop, which calls your callbacks until the connection closes. Don't block in the method, or no messages are read. - A request that isn't a WebSocket upgrade gets `426 Upgrade Required`. The `HTTP::WebSocket` methods you'll use most: | Method | Description | | ---------------------------------- | ---------------------------------------------------------------- | | `send(message)` | Sends a text message (a `String`) or a binary message (`Bytes`). | | \`on_message { | text | | \`on_binary { | bytes | | \`on_close { | code, reason | | \`on_ping { | message | | `close(code = nil, message = nil)` | Closes the connection. | | `closed?` | Whether the connection is closed. | ## Filters and authentication `before_action` and `around_action` [filters](https://spider-gazelle.net/guides/filters/index.md) run before the connection is upgraded. If a filter renders a response, such as `head :unauthorized`, the upgrade doesn't happen and the client receives that response instead. ``` class Notifications < AC::Base base "/notifications" @[AC::Route::Filter(:before_action)] def authenticate(token : String? = nil) head :unauthorized unless token == "letmein" end # Streams notifications to the client @[AC::Route::WebSocket("/")] def stream(socket) : Nil socket.send({event: "connected"}.to_json) socket.on_message { |message| socket.send(message.upcase) } end end ``` Browsers can't add custom headers, such as `Authorization`, to a WebSocket request. Authenticate with one of: - the [session](https://spider-gazelle.net/guides/sessions/index.md) or another cookie, which the browser sends automatically to the same site - a short-lived token in the query string, as above Note WebSocket routes can read the session, but changes to it aren't saved. The session is stored in a cookie, and there's no HTTP response to carry it once the connection is upgraded. If the controller uses `force_tls` (see [Responses](https://spider-gazelle.net/guides/responses/index.md)), an unencrypted `ws://` request is refused with `412 Precondition Failed` and the message `WebSocket Secure (wss://) connection required`, rather than being redirected. ## Concurrency Each connection runs in its own [fiber](https://crystal-lang.org/reference/guides/concurrency.html). Callbacks from different connections can run at the same time when your app is multi-threaded, so protect shared state, like `ROOMS` above, with a `Mutex`. Connections only exist in the process that accepted them. Threads within a process share them (see [workers and threads](https://spider-gazelle.net/getting_started/configuration/#workers-and-threads)), but if you run several containers or servers, a broadcast from one doesn't reach sockets held by another. Use a shared message bus, such as Redis pub/sub, to fan messages out between instances. ## The route DSL The `ws` macro defines the same kind of route without an annotation. The block arguments become the method's arguments: ``` class Echo < AC::Base base "/echo" ws "/", :echo do |socket| socket.on_message { |message| socket.send(message) } end end ``` Prefer the annotation, which supports typed params. ## OpenAPI and MCP WebSocket routes appear in the [OpenAPI](https://spider-gazelle.net/openapi/index.md) document as `GET` operations, using the method's doc comment. They aren't exposed as [MCP](https://spider-gazelle.net/mcp/index.md) tools, because a tool call is a single request and response. ## Testing The spec client can open a WebSocket to your routes in-process with `establish_ws`. See [Testing WebSockets](https://spider-gazelle.net/guides/testing/#testing-websockets). ## See also - [Filters](https://spider-gazelle.net/guides/filters/index.md) - [Sessions and cookies](https://spider-gazelle.net/guides/sessions/index.md) - [Testing](https://spider-gazelle.net/guides/testing/index.md) - [Routing](https://spider-gazelle.net/guides/routing/index.md) # Logging Spider-Gazelle logs through Crystal's standard [`Log`](https://crystal-lang.org/api/latest/Log.html) module. This page covers logging from your controllers, request logging, request IDs, output formats, redacting sensitive params and changing the log level while the app runs. ## Example Give your base controller a `Log`, and tag every log entry made during a request with a request ID: ``` require "action-controller" require "uuid" module App NAME = "my_app" Log = ::Log.for(NAME) end abstract class App::Base < AC::Base # Logs from controllers use the source "my_app.controller" Log = ::App::Log.for("controller") @[AC::Route::Filter(:before_action)] def set_request_id request_id = request.headers["X-Request-ID"]? || UUID.random.to_s Log.context.set(client_ip: client_ip, request_id: request_id) response.headers["X-Request-ID"] = request_id end end class App::Orders < App::Base base "/orders" # Creates an order @[AC::Route::POST("/:product_id")] def create(product_id : Int64, quantity : Int32 = 1) : NamedTuple(product_id: Int64, quantity: Int32) Log.info { "creating order" } Log.debug { "quantity #{quantity}" } {product_id: product_id, quantity: quantity} end end # Configure where logs go and at what level backend = ActionController.default_backend ::Log.setup "*", :info, backend ::Log.builder.bind "#{App::NAME}.*", :debug, backend ``` With the template's `LogHandler` in place (see [Request logging](#request-logging)), `POST /orders/42?quantity=3` logs: ``` level=[I] time=2026-10-02T06:23:18Z program=app source=my_app.controller message="creating order" client_ip=127.0.0.1 request_id=c6595fb2-7808-41bc-98e7-4be557b552c5 level=[D] time=2026-10-02T06:23:18Z program=app source=my_app.controller message="quantity 3" client_ip=127.0.0.1 request_id=c6595fb2-7808-41bc-98e7-4be557b552c5 level=[I] time=2026-10-02T06:23:18Z program=app source=action-controller client_ip=127.0.0.1 request_id=c6595fb2-7808-41bc-98e7-4be557b552c5 event=response method=POST path=/orders/42?quantity=3 status=200 duration=50.4µs ``` The [application template](https://github.com/spider-gazelle/spider-gazelle) sets all of this up for you, in `src/constants.cr`, `src/config.cr` and `src/controllers/application.cr`. ## Log sources Every log entry has a **source**, a dotted name that says where it came from. You configure levels per source. | Source | What logs there | | --------------------------- | --------------------------------------------------------- | | `action-controller` | Request logging from `LogHandler`, and framework warnings | | `action-controller.session` | Session cookies that can't be decoded | | `action-controller.mcp` | The [MCP server](https://spider-gazelle.net/mcp/index.md) | | `.*` | Your code, if you create loggers with `App::Log.for(...)` | Create a logger for your own code with `Log.for`. Chaining from an app-wide `Log` puts everything under one prefix, so you can set its level with a single `"my_app.*"` binding: ``` module App Log = ::Log.for("my_app") end class App::Billing Log = ::App::Log.for("billing") # source "my_app.billing" end ``` Warning Define `Log` in your base controller. If you don't, `Log` inside a controller resolves to the top-level `::Log`, whose source is empty, so you can't filter those entries by source. Write entries with the usual `Log` methods. Pass a block, so the message is only built when that level is enabled: ``` Log.trace { "very detailed" } Log.debug { "useful while developing" } Log.info { "something happened" } Log.warn { "something looks wrong" } Log.error(exception: error) { "something failed" } ``` ## Configuring output `Log.setup` sets the default level and backend for every source. `Log.builder.bind` then sets the level for specific sources. The template's `src/config.cr` does this: ``` if running_in_production? log_level = ::Log::Severity::Info ::Log.setup "*", :warn, LOG_BACKEND else log_level = ::Log::Severity::Debug ::Log.setup "*", :info, LOG_BACKEND end ::Log.builder.bind "action-controller.*", log_level, LOG_BACKEND ::Log.builder.bind "#{NAME}.*", log_level, LOG_BACKEND ``` | Environment | Your app and action-controller | Everything else (shards) | | -------------------------------- | ------------------------------ | ------------------------ | | development | `debug` | `info` | | production (`SG_ENV=production`) | `info` | `warn` | A pattern such as `"my_app.*"` matches `my_app` and every source below it. ### Formats `ActionController.default_backend(io = STDOUT, formatter = default_formatter)` returns a `Log::IOBackend`. Two formatters are provided: - `ActionController.default_formatter`: `key=value` text, shown above. Easy to read and to grep. - `ActionController.json_formatter`: one JSON object per line, for log tools such as Logstash, Loki or CloudWatch. ``` LOG_BACKEND = ActionController.default_backend(formatter: ActionController.json_formatter) ``` ``` {"level":"INFO","program":"app","time":"2026-10-02T16:23:18+10:00","source":"my_app.controller","message":"creating order","client_ip":"127.0.0.1","request_id":"c6595fb2-7808-41bc-98e7-4be557b552c5"} ``` Both include the context (such as `request_id`), any data passed to the entry, and the exception backtrace if there is one. `program` is `Log.progname`, which defaults to the name of the executable. ## Request logging `ActionController::LogHandler` is an HTTP handler that logs each request. Add it with `ActionController::Server.before`, before the server is created: ``` ActionController::Server.before( ActionController::ErrorHandler.new(App.running_in_production?, ["X-Request-ID"]), ActionController::LogHandler.new(["password", "bearer_token"]), HTTP::CompressHandler.new ) ``` It logs one `info` entry per response, with the method, path, status and duration. If a route raises an unhandled exception, it logs an `error` entry instead, with `status=500` and the backtrace. `LogHandler.new` takes: | Argument | Default | Description | | ------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `filter` | `[] of String` | Query string params whose values are logged as `[FILTERED]`. | | `log` | `Event::Response` | Which events to log. \`Event::Request | | `ms` | `false` | Log durations as a plain number of milliseconds, e.g. `0.0504`, instead of with a unit, e.g. `50.4µs`. Simpler for monitoring tools to parse. | | `generate_id` | `true` | When request events are logged, add a random `request_id` to the log context. | The events are `ActionController::LogHandler::Event` values. The handler wraps each request in its own log context. Anything your controller adds with `Log.context.set` appears on the handler's response entry too, as in the example at the top of this page. ### Filtering sensitive params ``` ActionController::LogHandler.new(["password", "bearer_token"]) ``` `GET /login?user=steve&password=hunter2` is logged as `path=/login?user=steve&password=[FILTERED]`. The filter only applies to the query string in the logged path. `LogHandler` never logs request bodies or headers, but your own log calls might, so take care not to log passwords or tokens yourself. ## Request IDs A request ID ties together every log entry made while handling one request, and lets clients quote it when reporting a problem. The template's base controller sets one in a `before_action` filter: ``` @[AC::Route::Filter(:before_action)] def set_request_id request_id = UUID.random.to_s Log.context.set( client_ip: client_ip, request_id: request_id ) response.headers["X-Request-ID"] = request_id end ``` - `Log.context.set` tags every later entry in this request, from any logger. - The `X-Request-ID` response header lets the client see the ID. - The template passes `["X-Request-ID"]` to `ErrorHandler`, so the header is kept on `500` responses, which is when it's needed most. - `client_ip` reads the `X-Forwarded-For`, `X-Real-IP` or `Forwarded` headers set by proxies, falling back to the connection's address. In a microservice, reuse the ID sent by the caller so one ID follows the request across services, and send it on to services you call: ``` request_id = request.headers["X-Request-ID"]? || UUID.random.to_s ``` Note If you log request events with `Event::Request`, the handler adds its own `request_id` before your filter runs, and the filter then replaces it. The request entry and the response entry end up with different IDs. Use one source of IDs: pass `generate_id: false`, or read the existing ID with `Log.context.metadata[:request_id]?` in your filter. ## Changing the log level at runtime The template lets you turn on `trace` logging in a running process without a restart, which is useful when debugging production. Send the process the `USR1` signal: ``` kill -USR1 ``` The process prints `> Log level changed to Trace`. Send `USR1` again to go back to `info` in production, or `debug` in development. This is implemented by `App.register_severity_switch_signals` in the template's `src/constants.cr`, and called from `src/app.cr`. It only changes the level of your app's sources (`"#{NAME}.*"`), not action-controller's or other shards'. It's not available on Windows. With several [workers](https://spider-gazelle.net/getting_started/configuration/#workers-and-threads), each worker is a separate process, so signal each one you want to change. ## See also - [Configuration](https://spider-gazelle.net/getting_started/configuration/index.md) - [Filters](https://spider-gazelle.net/guides/filters/index.md) - [Errors](https://spider-gazelle.net/guides/errors/index.md) - [Deployment](https://spider-gazelle.net/deployment/index.md) - [Crystal's `Log` documentation](https://crystal-lang.org/api/latest/Log.html) # Testing Spider-Gazelle apps are tested with Crystal's built-in [spec library](https://crystal-lang.org/reference/guides/testing.html). Action-controller adds a spec client that sends requests to your routes in-process, without starting a server or opening a port. This page covers request specs, unit specs, WebSockets and testing the MCP server. ## Example ``` # spec/welcome_spec.cr require "./spec_helper" describe App::Welcome do client = AC::SpecHelper.client it "welcomes you" do response = client.get("/") response.status_code.should eq 200 response.body.should eq %("You're being trampled by Spider-Gazelle!") end it "extracts params for you" do response = client.post("/api/400") JSON.parse(response.body).should eq({"result" => 400}) end end ``` Run the specs from the project root: ``` crystal spec ``` ## Setup The spec helper uses the [hot_topic](https://github.com/jgaskins/hot_topic) shard to make HTTP requests without a network. Add it as a development dependency (the template already does): ``` development_dependencies: hot_topic: github: jgaskins/hot_topic ``` Then create `spec/spec_helper.cr`: ``` require "spec" # Helper methods for testing controllers require "action-controller/spec_helper" # Your application config require "../src/config" ``` Require `src/config.cr`, not `src/app.cr`. `config.cr` loads your controllers and configuration, and `app.cr` would parse the command line and start the server. This split is why the template keeps the two files apart. See [Configuration](https://spider-gazelle.net/getting_started/configuration/#how-the-template-is-organised). Each spec file then starts with `require "./spec_helper"`. ## Request specs `AC::SpecHelper.client` returns an [`HTTP::Client`](https://crystal-lang.org/api/latest/HTTP/Client.html) connected directly to your routes. Use the normal client methods, `get`, `post`, `put`, `patch`, `delete` and `head`, with `headers:` and `body:`: ``` describe Widgets do client = AC::SpecHelper.client it "creates a widget" do response = client.post("/widgets/", headers: HTTP::Headers{"Content-Type" => "application/json"}, body: {name: "sprocket", colour: "red"}.to_json, ) response.status_code.should eq 201 Widget.from_json(response.body).name.should eq "sprocket" end # needs a YAML responder, like the one in the template's base controller it "responds with YAML when asked" do response = client.get("/widgets/1", headers: HTTP::Headers{"Accept" => "application/yaml"}) response.headers["Content-Type"].should start_with "application/yaml" end end ``` A request spec runs the whole route: param parsing, filters, the action, exception handlers and the responder. It's the closest test to a real request. Things to know: - **No middleware.** Handlers added with `ActionController::Server.before`, such as `LogHandler`, `ErrorHandler`, compression and static files, aren't run. Requests go straight to the router. - **Unhandled exceptions are raised in your spec.** Without `ErrorHandler`, an exception that no [exception handler](https://spider-gazelle.net/guides/errors/index.md) catches isn't turned into a `500`. It's raised by `client.get(...)`, so use `expect_raises` to test it. The template's base controller handles param errors, so a bad param still returns `400` or `422`. - **No cookie jar.** Cookies aren't kept between requests. Copy them yourself: ``` response = client.post("/session/?username=steve&password=secret") headers = HTTP::Headers.new response.cookies.add_request_headers(headers) client.get("/session/", headers: headers) ``` ## Unit specs To test a controller method directly, create a controller instance with `spec_instance`: ``` describe App::Welcome do it "generates a date header" do welcome = App::Welcome.spec_instance(HTTP::Request.new("GET", "/")) welcome.set_date_header.should contain("GMT") end end ``` `spec_instance(request = HTTP::Request.new("GET", "/"))` builds the controller with a context for that request. If the request matches a route, `route_params` is set from the path. Calling a method on the instance is a plain method call. Filters don't run, and you pass the arguments yourself, so nothing is parsed from the request. Use unit specs for helper methods and filters, and request specs for routes. ## Testing WebSockets The spec client adds `establish_ws(path, headers = HTTP::Headers.new)`, which opens an in-process connection to a [WebSocket route](https://spider-gazelle.net/guides/websockets/index.md) and returns an `HTTP::WebSocket`: ``` describe Notifications do client = AC::SpecHelper.client it "streams notifications" do socket = client.establish_ws("/notifications/?token=letmein") messages = [] of String socket.on_message do |message| messages << message socket.close if messages.size == 2 end socket.send "hi" socket.run # reads messages until the socket is closed messages.should eq [%({"event":"connected"}), "HI"] end it "rejects unauthenticated clients" do client.get("/notifications/").status_code.should eq 401 end end ``` `socket.run` blocks until the socket closes, so close it from a callback once you've received what you expect. If a filter rejects the connection, for example with `head :unauthorized`, `establish_ws` raises a `Socket::Error` with the status code. Test rejections with `expect_raises`, or with a plain `client.get` as above: ``` it "requires a token" do expect_raises(Socket::Error, /Status code was 401/) do client.establish_ws("/notifications/") end end ``` ## Testing your MCP server The [MCP server](https://spider-gazelle.net/mcp/index.md) is an endpoint like any other, so you can test it with JSON-RPC requests. Mount it on a `SpecHelper` router, then use that router's client. This is based on the template's `spec/mcp_spec.cr`: ``` require "./spec_helper" describe "MCP server" do router = AC::SpecHelper.new ActionController::MCPServer.mount(router, "/mcp") client = router.hot_topic headers = HTTP::Headers{ "Content-Type" => "application/json", "Accept" => "application/json", } # sends a JSON-RPC request and returns the result rpc = ->(method : String, params : JSON::Any) do body = {jsonrpc: "2.0", id: 1, method: method, params: params}.to_json response = client.post("/mcp", headers: headers, body: body) response.status_code.should eq 200 JSON.parse(response.body)["result"] end # every request after `initialize` must send the session id before_all do body = {jsonrpc: "2.0", id: 1, method: "initialize", params: {protocolVersion: "2025-11-25"}}.to_json response = client.post("/mcp", headers: headers, body: body) headers["Mcp-Session-Id"] = response.headers["Mcp-Session-Id"] end it "exposes the routes as tools" do rpc.call("tools/call", JSON.parse(%({"name": "open_toolbox", "arguments": {"name": "welcome"}}))) tools = rpc.call("tools/list", JSON.parse("{}"))["tools"].as_a.map(&.["name"].as_s) tools.should contain "welcome_api" result = rpc.call("tools/call", JSON.parse(%({"name": "welcome_api", "arguments": {"example": 42}}))) result["structuredContent"].should eq({"status" => 200, "body" => {"result" => 42}}) end end ``` - `AC::SpecHelper.new` is the router behind `AC::SpecHelper.client`, and `hot_topic` returns a client for it. You need the router itself to mount the MCP server on. - `ActionController::MCPServer` comes from `require "action-controller/mcp"`, which the template's `config.cr` already includes. - Tool calls go through your routes, so filters and exception handlers apply, as they do in production. - Tool descriptions come from `mcp.yml`. If it hasn't been generated, the tools still work but have no descriptions, and a warning is logged. Generate it with `crystal run src/app.cr -- --mcp=mcp.yml` if your specs check descriptions. The template's spec also checks [prompts](https://spider-gazelle.net/mcp/prompts/#prompts) with `prompts/list` and `prompts/get`, and that routes hidden with `@[AC::MCP(hide: true)]` aren't listed. ## Running specs ``` crystal spec # everything crystal spec spec/welcome_spec.cr # one file crystal spec spec/welcome_spec.cr:12 # the example on line 12 crystal spec -v --error-trace # list each example and show full backtraces ``` Add `focus: true` to an `it` or `describe` to run only that example while you work on it, and remove it before committing: ``` it "creates a widget", focus: true do # ... end ``` ### Continuous integration The template's `.github/workflows/ci.yml` checks formatting and runs the specs on every push and pull request, and once a week, against both Crystal `latest` and `nightly`. Simplified to a single Crystal version, it looks like this: ``` jobs: build: runs-on: ubuntu-latest container: crystallang/crystal:latest-alpine steps: - uses: actions/checkout@v4 - name: Install dependencies run: shards install --ignore-crystal-version - name: Format run: crystal tool format --check - name: Run tests run: crystal spec -v --error-trace ``` See [Deployment](https://spider-gazelle.net/deployment/#github-actions) for building and publishing a Docker image from CI. ## See also - [Routing](https://spider-gazelle.net/guides/routing/index.md) - [Errors](https://spider-gazelle.net/guides/errors/index.md) - [WebSockets](https://spider-gazelle.net/guides/websockets/index.md) - [Sessions and cookies](https://spider-gazelle.net/guides/sessions/index.md) - [MCP](https://spider-gazelle.net/mcp/index.md) # OpenAPI # OpenAPI Spider-Gazelle generates an [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) description of your API from the code you already write: route annotations, method signatures and doc comments. There's no separate spec file to maintain. ``` # Manages the comments on articles class Comments < AC::Base base "/articles/:article_id/comments" # Lists the comments on an article # # Comments are returned newest first. @[AC::Route::GET("/")] def index( article_id : Int64, @[AC::Param::Info(description: "only return comments by this author", example: "steve")] author : String? = nil, ) : Array(Comment) Comment.for(article_id, author) end end ``` That produces a `GET /articles/{article_id}/comments` operation with: - a summary ("Lists the comments on an article") and a description; - a required `article_id` path parameter (`integer`) and an optional, described `author` query parameter; - a `200` response containing an array of `Comment`, with the `Comment` JSON schema generated from the class; - a "Comments" tag, so the operation is grouped with the controller's other routes. ## Where each part comes from | OpenAPI | Comes from | | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Path summary/description | doc comment on the controller class | | Operation summary | first line of the method's doc comment | | Operation description | the whole doc comment, when it's more than one line | | `operationId`, tags | `Controller_method`, and the controller's name. Repeats get a `_2`, `_3`... suffix | | Path parameters | `:name` segments in the route. Optional `?:name` and glob `*:name` segments are [listed as separate paths](https://spider-gazelle.net/openapi/descriptions/#optional-path-segments) | | Query parameters | other method arguments. Required unless nilable or defaulted | | Header parameters | `@[AC::Param::Info(header: "X-Name")]` | | Parameter description/example | `@[AC::Param::Info(description:, example:)]` | | Parameter schemas | argument types (`Int32`, `UUID`, enums, `Time`, ...) | | Request body | the `body:` argument's type, for each registered parser content type | | Responses | the return type, `status_code:` and `status:` maps, and your exception handlers | | Schemas | `JSON::Serializable` types and enums via [json-schema](https://github.com/spider-gazelle/json-schema), with `@[JSON::Field]` hints. Each is defined once under `components/schemas`, and referenced with `$ref` wherever it's used, including when nested | | Filter parameters | arguments of the filters that apply to the route | ## Why it's worth it One source of truth The OpenAPI document is generated from the same code that handles requests, so it can't describe a parameter that doesn't exist or miss one that does. Change a type and the schema changes; rename an argument and the parameter is renamed. Reviewers read one diff and the docs are always current. The document is generally useful too: - Browse and try your API in [Swagger UI](https://editor.swagger.io/), Redoc or Scalar. - Generate typed API clients for TypeScript, Python, Swift, Kotlin and more with [OpenAPI Generator](https://openapi-generator.tech/). - Contract test, mock, and import into API gateways. The same metadata also powers the [MCP server](https://spider-gazelle.net/mcp/index.md). Improving your OpenAPI descriptions improves the tools your AI agents see. ## In this section - [Describing routes](https://spider-gazelle.net/openapi/descriptions/index.md): doc comments, parameters, headers, request bodies and responses. - [Schemas](https://spider-gazelle.net/openapi/schemas/index.md): how types become JSON schema, and how to refine it. - [Generating and serving](https://spider-gazelle.net/openapi/generating/index.md): the CLI, Docker builds, serving the document, client generation and troubleshooting. # Describing routes This page shows how to make each operation in your OpenAPI document as useful as possible. The same comments and annotations describe your [MCP tools](https://spider-gazelle.net/mcp/index.md), so this work pays off twice. ## Summaries and descriptions Doc comments directly above a route become its summary and description: - the **first line** is the summary; - if there's more than one line, the **whole comment** is also the description. ``` # Comments left on articles class Comments < AC::Base base "/comments" # Lists every comment @[AC::Route::GET("/")] def index : Array(Comment) Comment.all end # Creates a comment # # The author is taken from the signed in user. Markdown is supported in the body. @[AC::Route::POST("/", body: :comment, status_code: HTTP::Status::CREATED)] def create(comment : Comment) : Comment comment.save! end end ``` The comment above the **controller class** describes the controller's paths. It's also used as the [MCP toolbox](https://spider-gazelle.net/mcp/#how-agents-see-your-api) description. Keep developer notes out of doc comments Everything in the doc comment is published. Put implementation notes in a separate comment block, separated from the doc comment by a blank line: ``` # NOTE: cached for 5 minutes, see CacheConfig # Lists every comment @[AC::Route::GET("/")] def index : Array(Comment) ``` Comments are inherited: if a method is defined on a parent controller, its doc comment is used for the routes of every subclass. ## Parameters Every method argument is a parameter. Its location comes from the route: | Argument | Location | Required | | ------------------------------------------ | -------------------- | --------------------------------- | | Matches a `:name` segment | path | yes | | Matches a `?:name` or `*:name` segment | path, see below | yes, in the paths that include it | | Has `@[AC::Param::Info(header: "X-Name")]` | header | unless nilable or defaulted | | The `body:` argument | request body | yes | | Anything else | query (or form data) | unless nilable or defaulted | Describe parameters with `@[AC::Param::Info]`: ``` @[AC::Route::GET("/")] def index( @[AC::Param::Info(description: "only return comments by this author", example: "steve")] author : String? = nil, @[AC::Param::Info(description: "maximum number of results", example: "20")] limit : Int32 = 20, @[AC::Param::Info(name: "q", description: "full text search", example: "crystal")] query : String? = nil, @[AC::Param::Info(header: "X-Tenant", description: "the tenant to query", example: "acme")] tenant : String? = nil, ) : Array(Comment) ``` | `Param::Info` option | Effect | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `description:` | parameter description | | `example:` | example value, written as it appears in a URL. The document gives it the parameter's type: `"20"` becomes `20`, `"true"` becomes `true`, and `"a,b"` for an array becomes `["a", "b"]` | | `name:` | the public parameter name, when it differs from the argument (`?q=` → `query`) | | `header:` | read from this request header rather than the query string | | `class:`, `config:` | a [custom converter](https://spider-gazelle.net/guides/parameters/index.md) and its options. The schema still comes from the argument type | Note Whether a parameter is required comes from the type: nilable or defaulted arguments are optional, everything else is required. There's no `required:` option to keep in sync. ### Optional path segments OpenAPI path parameters are always required, so a route with optional (`?:name`) or glob (`*:name`) segments is listed once for each path the router matches. The path without them comes first, then each segment is added in turn: | Route | Paths in the document | `operationId` | | -------------------------------- | --------------------------------- | ------------------------------------ | | `/eink/:item_id/?:expires_after` | `/eink/{item_id}` | `Signage_eink_without_expires_after` | | | `/eink/{item_id}/{expires_after}` | `Signage_eink` | The full path keeps the method's `operationId`. A shorter path gets `_without_` and the name of the first segment it leaves out. ### Array parameters An array parameter is sent as a single comma separated value, `?tags=a,b`, so the document marks it `style: form` and `explode: false`. Client generators then build the query string the way action-controller reads it. ### Parameters from filters Filters can take typed parameters too. When a filter applies to a route, its parameters are added to that route's operation: ``` abstract class Application < AC::Base @[AC::Route::Filter(:before_action)] def set_tenant( @[AC::Param::Info(header: "X-Tenant", description: "the tenant to use")] tenant : String, ) @tenant = Tenant.find!(tenant) end end ``` Every route in every controller inheriting from `Application` documents the `X-Tenant` header. ## Request bodies Name the argument that holds the body with `body:`: ``` @[AC::Route::POST("/", body: :comment)] def create(comment : Comment) : Comment ``` The request body schema is the argument's type. It's listed once for each content type your app can parse (JSON by default; see [parsers](https://spider-gazelle.net/guides/parameters/index.md)). ## Responses The return type is the response schema. The status code defaults to `200`: ``` # 201 Created, with a Comment body @[AC::Route::POST("/", body: :comment, status_code: HTTP::Status::CREATED)] def create(comment : Comment) : Comment # 202 Accepted, with no body @[AC::Route::DELETE("/:id", status_code: HTTP::Status::ACCEPTED)] def destroy(id : Int64) : Nil ``` Map different return types to different status codes with `status:`. Each mapping is documented as its own response: ``` @[AC::Route::GET("/:id", status: { Comment => HTTP::Status::OK, CommentRedirect => HTTP::Status::SEE_OTHER, })] def show(id : Int64) : Comment | CommentRedirect ``` Responses are listed for each content type your app can render (see [responders](https://spider-gazelle.net/guides/responses/index.md)). JSON and YAML responses reference the schema; `text/*` responses are strings. ### Error responses [Exception handlers](https://spider-gazelle.net/guides/errors/index.md) that apply to a route add their responses to its operation. Handlers in a base class document the errors for every route that inherits them: ``` abstract class Application < AC::Base # 404 Not Found, with a CommonResponse body, on every route @[AC::Route::Exception(AC::Error::NotFound, status_code: HTTP::Status::NOT_FOUND)] def not_found(error) : AC::Error::CommonResponse AC::Error::CommonResponse.new(error, backtrace: false) end end ``` Declare return types Without a return type the operation has no response schema. Declaring one also has the compiler check your method returns what you documented. ## What isn't included - Routes defined with the macro DSL (`get "/" do ... end`) aren't documented. Use annotations for anything you want described. - `@[AC::Route::OPTIONS]` routes aren't documented. - Handlers registered with [`rescue_from`](https://spider-gazelle.net/guides/errors/#rescue_from) don't add responses. Only annotated exception handlers do. - MCP prompts (`@[AC::MCP(prompt: true)]`) aren't HTTP routes, so they aren't in the document. ## See also - [Schemas](https://spider-gazelle.net/openapi/schemas/index.md) - [Parameters guide](https://spider-gazelle.net/guides/parameters/index.md) - [Generating and serving](https://spider-gazelle.net/openapi/generating/index.md) # Schemas Every type that appears in a route, whether a parameter, request body or response, is described with [JSON Schema](https://json-schema.org/). The schemas are generated at compile time by the [json-schema](https://github.com/spider-gazelle/json-schema) shard, so they always match your actual types. ## Types you get for free | Crystal type | Schema | | ---------------------------------- | ------------------------------------------------------------- | | `String` | `string` | | `Int32`, `Int64`, `UInt8`, ... | `integer` with a `format` such as `Int64` | | `Float32`, `Float64` | `number` | | `Bool` | `boolean` | | `Time` | `string`, `format: date-time` | | `UUID` | `string`, `format: uuid` | | `Enum` | `string` with an `enum` list of the member names (snake case) | | `Array(T)`, `Set(T)`, `Tuple` | `array` | | `Hash(String, T)` | `object` with `additionalProperties` | | `NamedTuple`, `JSON::Serializable` | `object` with `properties` and `required` | | \`A | B\` | Enums and `JSON::Serializable` types are defined once as named components and referenced wherever they're used. See [how schemas appear in the document](#how-schemas-appear-in-the-document). ## Your models Include `JSON::Serializable` and the schema follows your class. Required properties are those that aren't nilable and have no default, matching how `from_json` behaves. ``` # A comment left on an article class Comment include JSON::Serializable getter id : Int64 getter author : String @[JSON::Field(description: "markdown formatted")] getter body : String @[JSON::Field(key: "created_at")] getter created : Time @[JSON::Field(ignore: true)] getter cache_key : String = "" end ``` - The **class doc comment** becomes the schema's description. - `key:` renames properties, and `ignore: true` leaves them out, exactly as the JSON serialiser does. - `description:` documents an individual property. ## Refining a property `@[JSON::Field]` accepts JSON Schema keywords that tighten validation hints in the document: ``` class Signup include JSON::Serializable @[JSON::Field(format: "email")] getter email : String @[JSON::Field(min_length: 8, max_length: 64)] getter password : String @[JSON::Field(pattern: "^[a-z0-9_]+$")] getter username : String @[JSON::Field(minimum: 13, maximum: 130)] getter age : Int32 # stored as a Time, but the JSON value is a unix timestamp @[JSON::Field(converter: Time::EpochConverter, type: "integer", format: "Int64")] getter joined : Time end ``` The supported keys are `type`, `format`, `pattern`, `min_length`, `max_length`, `multiple_of`, `minimum`, `exclusive_minimum`, `maximum`, `exclusive_maximum` and `description`. Note These are documentation hints. They don't add runtime validation. Validate in your model or with a library such as [active-model](https://github.com/spider-gazelle/active-model). When you use a `converter:`, override `type` and `format` so the schema describes the JSON value rather than the Crystal type. ## Custom types If a type serialises itself some other way, describe it by implementing `self.json_schema`. Return a `NamedTuple` (or anything with `to_json`) in JSON Schema form: ``` struct Money def self.json_schema(openapi : Bool? = nil) {type: "string", pattern: "^\\d+\\.\\d{2}$", description: "an amount, e.g. 12.50"} end def to_json(json : JSON::Builder) json.string(to_s) end end ``` A type like `Money` is inlined wherever it's used. If a `JSON::Serializable` type or an enum defines `self.json_schema`, it still gets its own component (see below), and that method provides the definition. ## How schemas appear in the document Every `JSON::Serializable` type and enum is defined once under `components/schemas`. Everywhere it's used, it's referenced with `$ref`: in request bodies, responses, parameters, and nested inside other types. Each model is described in one place, so client generators produce one named type per model rather than an anonymous copy at every use. ``` module Shop # A line on an order struct Item include JSON::Serializable getter sku : String getter quantity : Int32 end enum Status Pending Shipped end # An order placed in the shop class Order include JSON::Serializable getter items : Array(Item) getter status : Status getter replaces : Order? end struct Page(T) include JSON::Serializable getter results : Array(T) getter total : Int32 end end class Orders < AC::Base base "/orders" @[AC::Route::GET("/")] def index(status : Shop::Status? = nil) : Shop::Page(Shop::Order) raise NotImplementedError.new("load the orders") end end ``` The generated components: ``` components: schemas: Shop.Page-oShop.Order-c: type: object properties: results: type: array items: $ref: '#/components/schemas/Shop.Order' total: type: integer format: Int32 required: [results, total] Shop.Order: type: object properties: items: type: array items: $ref: '#/components/schemas/Shop.Item' status: $ref: '#/components/schemas/Shop.Status' replaces: anyOf: - type: "null" - $ref: '#/components/schemas/Shop.Order' required: [items, status] description: An order placed in the shop Shop.Item: type: object properties: sku: type: string quantity: type: integer format: Int32 required: [sku, quantity] description: A line on an order Shop.Status: type: string enum: [pending, shipped] ``` - **Nested types are referenced too.** `Shop::Item` only ever appears inside `Order`, and it's still a component of its own. The type's doc comment becomes its description. - **Nilable references** such as `replaces : Order?` are an `anyOf` of the reference and `{type: "null"}`. The optional `status` parameter is described the same way. - **Self-referencing types**, like `Order` above, work. - **Enums are referenced by type.** Two enums with the same members stay separate components. - **Everything else is inlined where it's used:** strings, numbers, arrays, hashes, unions and tuples, and custom types like `Money`. ### Component names A component's name is derived from the type's full name. Only the characters OpenAPI allows are used, and the conversion is reversible, so two different types never share a name: | Type | Component name | | ------------------------- | ------------------------- | | `Item` | `Item` | | `Shop::Item` | `Shop.Item` | | `Shop::Page(Shop::Order)` | `Shop.Page-oShop.Order-c` | | `Pair(A::B, C)` | `Pair-oA.B-nC-c` | | `Pair(A, B::C)` | `Pair-oA-nB.C-c` | `::` becomes `.`. In generic types, `(`, `)`, `,` and `|` become `-o`, `-c`, `-n` and `-p`. Any other character becomes `-u` followed by its six digit hex code. ### OpenAPI 3.0 The document is OpenAPI 3.1 by default, whose schemas are standard JSON Schema 2020-12. For tools that only read OpenAPI 3.0, ask for 3.0.3: ``` ActionController::OpenAPI.generate_open_api_docs(title: "Shop", version: "1.0", openapi: "3.0.3") ``` OpenAPI 3.0 has its own dialect of JSON Schema, so a few things are written differently: | | 3.1 (default) | 3.0.3 | | -------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------- | | A nilable `T?` | `anyOf` with `{type: "null"}` | `nullable: true` | | A nilable or described reference | `anyOf` with `{type: "null"}`, or `$ref` beside `description` | `allOf: [$ref]` with `type`, `nullable` and `description` | | `Tuple(A, B)` | `prefixItems: [A, B]` | `items: {anyOf: [A, B]}` with `minItems`/`maxItems` | | `exclusive_minimum: 5` | `exclusiveMinimum: 5` | `minimum: 5, exclusiveMinimum: true` | ### MCP tools In [MCP tools](https://spider-gazelle.net/mcp/index.md), each tool's input schema is self-contained. The components it references, including nested ones, are included as `$defs`, and nullable types are expressed in standard JSON Schema. ## See also - [Describing routes](https://spider-gazelle.net/openapi/descriptions/index.md) - [json-schema shard](https://github.com/spider-gazelle/json-schema) # Generating and serving This page covers producing the OpenAPI document from your app, at build time or from the command line, and then serving it, committing it and generating clients from it. ## How generation works Most of the document is built at compile time from your annotations and types. Doc comments aren't available to compiled code, so they're extracted by running `crystal docs` over your source when the document is generated. Generate where the source code is Generation needs the `crystal` compiler, your `src/` directory and `shard.yml` (for `crystal docs`). Generate the document at build time, or during development. A deployed binary without its source can't generate it. ## From the command line The [application template](https://github.com/spider-gazelle/spider-gazelle) adds a `--docs` flag: ``` crystal build src/app.cr -o app ./app --docs # print the YAML document ./app --docs --file=openapi.yml # save it to a file ``` Behind the flag is a single call that you can use in any app: ``` require "action-controller" require "action-controller/server" docs = ActionController::OpenAPI.generate_open_api_docs( title: "My API", version: "1.0.0", description: "Everything you need to manage widgets", ) File.write("openapi.yml", docs.to_yaml) ``` `title` and `version` are required. Any other [info object](https://spec.openapis.org/oas/v3.1.0#info-object) fields, such as `description`, `termsOfService` or `contact`, can be passed as named arguments. The result is a `NamedTuple`, so `.to_json` works too. The document is OpenAPI 3.1. Pass `openapi: "3.0.3"` for tools that only read 3.0. See [how 3.0 schemas differ](https://spider-gazelle.net/openapi/schemas/#openapi-30). ## In Docker builds The template's Dockerfile generates the document in the build stage, where the source is available, and copies it into the final image: ``` # Generate OpenAPI docs and MCP tool descriptions while we still have source code access RUN ./bin/app --docs --file=openapi.yml && \ ./bin/app --mcp=mcp.yml # ... COPY --from=build /app/openapi.yml /openapi.yml ``` ## Serving the document Load the generated file at startup and return it from a route: ``` class Docs < AC::Base base "/" # generated during the docker build OPENAPI = YAML.parse(File.exists?("openapi.yml") ? File.read("openapi.yml") : "{}") # returns the OpenAPI representation of this service @[AC::MCP(hide: true)] @[AC::Route::GET("/openapi")] def openapi : YAML::Any OPENAPI end end ``` `@[AC::MCP(hide: true)]` keeps the document out of your [MCP tools](https://spider-gazelle.net/mcp/index.md): it's for people and code generators, not models. Point [Swagger UI](https://editor.swagger.io/), [Redoc](https://redocly.github.io/redoc/) or [Scalar](https://scalar.com/) at `/openapi` to browse it. ## Committing the document Many projects also commit the generated file (e.g. `OPENAPI_DOC.yml`). Reviewers can then see API changes in pull requests, and clients can be generated from the repository. Regenerate it whenever routes change: ``` crystal build src/app.cr -o app && ./app --docs > OPENAPI_DOC.yml && rm app ``` ## Generating clients Any OpenAPI tool can consume the document. For example, a TypeScript client with [OpenAPI Generator](https://openapi-generator.tech/): ``` npx @openapitools/openapi-generator-cli generate \ -i openapi.yml -g typescript-fetch -o ./client ``` Because the document is generated from the server code, regenerating the client after an API change gives you compile errors exactly where your frontend needs updating. ## Troubleshooting | Symptom | Cause | | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `failed to obtain route descriptions via 'crystal docs'` | generation ran without the source code, `crystal`, or `shard.yml` in the working directory | | A route is missing | it was defined with the macro DSL (`get "/" do`), so use an annotation, or it's an `OPTIONS` route, which isn't documented | | No response schema | the method has no return type | | A summary contains notes meant for developers | separate those notes from the doc comment with a blank line | ## See also - [Composable applications](https://spider-gazelle.net/guides/composition/index.md): one document for selected apps and mounted paths - [Describing routes](https://spider-gazelle.net/openapi/descriptions/index.md) - [MCP setup](https://spider-gazelle.net/mcp/setup/index.md), which uses the same build step for `mcp.yml` - [Deployment](https://spider-gazelle.net/deployment/index.md) # MCP # MCP server Spider-Gazelle can expose your API to AI agents as a [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server. Claude, VS Code, Cursor and other MCP clients can then discover and call your routes as **tools**, and offer your **prompts** to users. There are no tool definitions to write: they're generated from the same annotations, types and doc comments that drive routing and [OpenAPI](https://spider-gazelle.net/openapi/index.md). ``` claude mcp add --transport http my-app http://localhost:3000/mcp ``` ## Why it's built in Your API is already the tool definition Hand-written tool definitions duplicate your API, and they drift: a renamed parameter or a changed type quietly breaks the agent. In Spider-Gazelle the tool *is* the route: - the **description** is the method's doc comment; - the **input schema** is generated from the argument types, `@[AC::Param::Info]` and the request body type; - a **call** runs the real route in-process, with the same parameter parsing, filters (authentication!), error handlers and responders as an HTTP request. Improve a doc comment and your OpenAPI docs and agent tools both get better. Fix a validation bug and it's fixed for agents too. ## How agents see your API Large APIs have hundreds of routes, and listing them all would fill the model's context before it does any work. Spider-Gazelle uses **progressive disclosure** instead: - **Toolboxes:** each controller is a toolbox, described by the controller's doc comment. - **Starting tools:** a session starts with just five tools (three with `tool_proxy = false`), plus any [root items](https://spider-gazelle.net/mcp/prompts/index.md): | Tool | Purpose | | --------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `list_toolboxes` | lists the toolboxes, their descriptions, and tool and prompt counts | | `open_toolbox(name)` | adds a toolbox's tools and prompts to the session, returning the tool definitions | | `close_toolbox(name)` | removes them again | | `call_read_only(name, arguments)` | runs a read only tool from an open toolbox, or a root tool | | `call_tool(name, arguments)` | runs any tool from an open toolbox, or a root tool. Tools marked `visibility: :card` are refused by both proxies | - **Loading on demand:** the model opens only the toolboxes it needs. The client is notified (`tools/list_changed`) and refreshes its tool list. - **Clients that don't refresh:** some clients (currently including Claude and ChatGPT) ignore `tools/list_changed`, so opened tools never appear. `open_toolbox` also returns each tool's name, description, input schema and the proxy to run it with: `call_read_only` for [read only tools](https://spider-gazelle.net/mcp/prompts/#behaviour), which clients can run without asking for confirmation, and `call_tool` for the rest. Proxied calls behave exactly like direct ones. Turn them off with `tool_proxy = false` once your clients support `list_changed`. - **Root items:** anything marked `root: true` is always available without opening a toolbox. Use it for the few tools and prompts most sessions need. ``` # Manage meeting rooms and their bookings class Rooms < AC::Base base "/rooms" # Lists the rooms in a building @[AC::Route::GET("/")] def index(building_id : String) : Array(Room) Room.where(building_id: building_id).to_a end end ``` Here the toolbox is `rooms` ("Manage meeting rooms and their bookings"), and opening it adds the `rooms_index` tool ("Lists the rooms in a building"). ## Naming - **Toolbox names:** the snake case controller name. The module namespace shared by every controller is left out, so `MyApp::Api::Rooms` and `MyApp::Api::Rooms::Bookings` become `rooms` and `rooms_bookings`. - **Tool and prompt names:** `_`, such as `rooms_index`. - **Multiple routes:** a method with several route annotations is one tool. It uses the method's first `GET` route, otherwise its first route in verb order (`POST`, `PUT`, `PATCH`, `DELETE`). ## What's exposed | | Exposed? | | ------------------------------------------- | ------------------------------------------------------------------ | | Annotated routes (`@[AC::Route::GET]` etc.) | yes, as tools | | Methods marked `@[AC::MCP(prompt: true)]` | yes, as [prompts](https://spider-gazelle.net/mcp/prompts/index.md) | | Routes marked `@[AC::MCP(hide: true)]` | no | | WebSocket and `OPTIONS` routes | no | | Macro DSL routes (`get "/" do`) | no | ## Calls run as the user Tool calls forward the client's `Authorization`, `Cookie` and `X-API-Key` headers to the route, so your existing authentication applies to every call. For OAuth sign-in from MCP clients, see [Authentication](https://spider-gazelle.net/mcp/authentication/index.md). ## In this section - [Setup](https://spider-gazelle.net/mcp/setup/index.md): mounting the server, generating `mcp.yml`, connecting clients and testing. - [Annotation options](https://spider-gazelle.net/mcp/prompts/index.md): prompts, `root`, `hide`, behaviour, visibility, titles and icons. - [Controller endpoints](https://spider-gazelle.net/mcp/endpoints/index.md): serving a controller as its own MCP server, such as one per room or account. - [UI cards](https://spider-gazelle.net/mcp/ui/index.md): interactive HTML cards for tool results (MCP Apps). - [Authentication](https://spider-gazelle.net/mcp/authentication/index.md): API keys, and OAuth sign-in with multi_auth and authly. - [Configuration](https://spider-gazelle.net/mcp/configuration/index.md): every option, and transport details. # Setting up MCP The [application template](https://github.com/spider-gazelle/spider-gazelle) has MCP set up already: run the app and connect a client to `http://localhost:3000/mcp`. This page explains each piece, so you can add MCP to an existing app or customise the template. ## 1. Require and configure MCP is an optional part of action-controller. Require it after your controllers, and configure it in `src/config.cr`: ``` require "action-controller" require "./controllers/*" require "action-controller/server" require "action-controller/mcp" ActionController::MCPServer.tap do |mcp| mcp.server_name = "my-app" mcp.server_version = "1.0.0" mcp.description_path = ENV["SG_MCP_DESCRIPTION"]? || "mcp.yml" end ``` ## 2. Mount the endpoint Mount the server on your `ActionController::Server` when it's created (in the template, `src/app.cr`): ``` server = ActionController::Server.new(port, host) ActionController::MCPServer.mount(server, "/mcp") server.run ``` `mount` registers `POST`, `GET` and `DELETE` handlers at the path for the [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) transport, the OAuth protected resource metadata at `/.well-known/oauth-protected-resource`, and any [controller endpoints](https://spider-gazelle.net/mcp/endpoints/index.md) (`endpoints: false` skips them). In the template the path comes from `SG_MCP_PATH`; set it to an empty string to disable MCP. ## 3. Generate the tool descriptions Tool and toolbox descriptions come from your doc comments. Like the [OpenAPI document](https://spider-gazelle.net/openapi/generating/index.md), these are extracted with `crystal docs`, so they're generated where the source is available and shipped with the binary as `mcp.yml`: ``` ./app --mcp=mcp.yml ``` The template's flag calls: ``` ActionController::MCPServer.write_description("mcp.yml") ``` The Dockerfile generates it during the build and copies it next to the binary: ``` RUN ./bin/app --docs --file=openapi.yml && \ ./bin/app --mcp=mcp.yml COPY --from=build /app/mcp.yml /mcp.yml ``` `mcp.yml` is loaded the first time a client connects. If it's missing, the server builds the tools from the compiled routes alone and logs a warning; everything works, but the model sees no descriptions. Write descriptions for the model The model chooses tools by their descriptions. A one-line summary of what a route does, plus `@[AC::Param::Info(description:)]` on non-obvious parameters, makes a big difference. These are the same comments that document your OpenAPI. ## 4. Connect a client ``` claude mcp add --transport http my-app http://localhost:3000/mcp # with an API key claude mcp add --transport http my-app http://localhost:3000/mcp \ --header "X-API-Key: " ``` ``` { "mcpServers": { "my-app": { "type": "http", "url": "https://my-app.example.com/mcp" } } } ``` `.vscode/mcp.json`: ``` { "servers": { "my-app": { "type": "http", "url": "http://localhost:3000/mcp" } } } ``` The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) is the best way to see exactly what your server exposes: ``` npx @modelcontextprotocol/inspector ``` Choose **Streamable HTTP** and enter `http://localhost:3000/mcp`. ## 5. Test it The spec helper drives the MCP endpoint in-process like any other route: ``` require "./spec_helper" describe "MCP" do router = AC::SpecHelper.new ActionController::MCPServer.mount(router, "/mcp") client = router.hot_topic headers = HTTP::Headers{"Content-Type" => "application/json", "Accept" => "application/json"} it "lists the toolboxes" do init = client.post("/mcp", headers: headers, body: { jsonrpc: "2.0", id: 1, method: "initialize", params: {protocolVersion: "2025-11-25"}, }.to_json) headers["Mcp-Session-Id"] = init.headers["Mcp-Session-Id"] response = client.post("/mcp", headers: headers, body: { jsonrpc: "2.0", id: 2, method: "tools/call", params: {name: "list_toolboxes", arguments: {} of String => String}, }.to_json) toolboxes = JSON.parse(response.body)["result"]["structuredContent"]["toolboxes"] toolboxes.as_a.map(&.["name"]).should contain "welcome" end end ``` See the template's `spec/mcp_spec.cr` for tool calls and prompts. ## See also - [Composable applications](https://spider-gazelle.net/guides/composition/index.md): a global MCP server for combined apps - [Annotation options](https://spider-gazelle.net/mcp/prompts/index.md) - [Authentication](https://spider-gazelle.net/mcp/authentication/index.md) - [Configuration](https://spider-gazelle.net/mcp/configuration/index.md) # Annotation options `@[AC::MCP]` controls how a controller or method appears to MCP clients: | Option | Applies to | Effect | | ----------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `prompt: true` | methods | the method is an MCP prompt rather than a route | | `root: true` | controllers or methods | always available, without opening the toolbox | | `hide: true` | controllers or methods | not exposed over MCP | | `title: "Book a room"` | methods | the tool or prompt's display name | | `behaviour: :read_only` | controllers or methods | what the tool does, see [behaviour](#behaviour) | | `visibility: :card` | controllers or methods | who can call the tool: `:model`, `:card` or both (the default) | | `endpoint: true` | controllers | also serves the controller as its own MCP server, see [controller endpoints](https://spider-gazelle.net/mcp/endpoints/index.md) | | `ui: "path.html"` | controllers or methods | renders an HTML card for the tool's results, see [UI cards](https://spider-gazelle.net/mcp/ui/index.md) | Tools with `ui:` or `visibility: :card` are root items unless annotated `root: false`. Icons have their own annotation, see [icons](#icons). On a controller, `root` and `hide` apply to every route and prompt in it, and `behaviour`, `visibility` and `ui` to every route. `title:` is only read on methods. They aren't inherited by subclasses, so annotate each controller. A method level annotation takes precedence, so you can hide a controller but expose one route: ``` @[AC::MCP(hide: true)] class Internal < AC::Base # exposed even though the rest of the controller is hidden @[AC::MCP(hide: false)] @[AC::Route::GET("/status")] def status : String "ok" end end ``` ## Prompts [Prompts](https://modelcontextprotocol.io/specification/2025-11-25/server/prompts) are reusable message templates that users pick in their MCP client, often shown as slash commands. They're a good way to package your domain knowledge: what to ask, which tools to use, and in what order. ``` class Welcome < AC::Base base "/" # Learn a surprising fact about a number @[AC::MCP(prompt: true, root: true)] def number_fact( @[AC::Param::Info(description: "the number to share a fact about", example: "42")] number : Int32, @[AC::Param::Info(description: "who the fact is for", example: "a curious five year old")] audience : String = "a general audience", ) : String <<-PROMPT Share one surprising fact about the number #{number}, explained for #{audience}. Use the welcome toolbox to confirm the number with this service first. PROMPT end end ``` - **Description:** the doc comment is what users see in their client. - **Arguments:** the method's arguments, described with `@[AC::Param::Info]`. They're required unless nilable or defaulted, exactly like route parameters. - **Return type:** must be declared, and must be `String` (one user message) or `Array(AC::PromptMessage)` (a conversation). ### Conversations Return messages to prime a multi-turn exchange: ``` # Review a pull request with the team's checklist @[AC::MCP(prompt: true)] def review(id : Int64) : Array(AC::PromptMessage) pr = PullRequest.find!(id) [ AC::PromptMessage.user("Review pull request ##{id}: #{pr.title}\n\n#{pr.diff}"), AC::PromptMessage.assistant("I'll check it against the checklist. Which areas worry you most?"), ] end ``` ### Prompts run like routes A prompt isn't an HTTP route, so it can't be reached over HTTP and isn't in your OpenAPI document. Internally, though, it runs through the same pipeline as a route: - **Parameters** are parsed and validated by the same converters, including `config:` and custom converters. - **Filters** run: `before_action` authentication, loading records, and so on. - **Exception handlers** apply. So a prompt can safely load and embed data, such as the pull request above, with the user's permissions enforced. Warning A prompt that is also a route is a compile error, and so is a prompt without a `String` or `Array(AC::PromptMessage)` return type. ## Root tools and prompts Toolboxes keep the model's context small, but a few tools are needed in almost every session. Mark them `root: true`: ``` class Rooms < AC::Base # always listed, no need to open the rooms toolbox @[AC::MCP(root: true)] @[AC::Route::GET("/search")] def search(query : String) : Array(Room) Room.search(query) end end # every route and prompt in the controller is a root item @[AC::MCP(root: true)] class Me < AC::Base end ``` Root items keep their `_` names. A toolbox that only contains root items isn't listed by `list_toolboxes`, as there's nothing to open. ## Hiding routes Hide routes that aren't useful or safe for a model, such as health checks, webhooks, binary downloads or large documents: ``` # the OpenAPI document is for people and code generators, not models @[AC::MCP(hide: true)] @[AC::Route::GET("/openapi")] def openapi : YAML::Any OPENAPI end ``` Hiding isn't access control `hide: true` only removes a tool from the MCP listing; the HTTP route still works. Protect routes with [filters](https://spider-gazelle.net/guides/filters/index.md) as usual. Tool calls run your filters too. ## Behaviour Hosts use a tool's behaviour to decide what to confirm with the user. It's inferred from the HTTP verb: | Verb | Behaviour | | ----------- | ----------------------------- | | GET | `:read_only` | | PUT | `:idempotent` | | DELETE | `[:destructive, :idempotent]` | | POST, PATCH | none | Set `behaviour:` when the verb is misleading. It's a symbol or an array of `:read_only`, `:additive`, `:destructive`, `:idempotent`, `:open_world` and `:closed_world`, and it replaces the inferred behaviour. Contradictions, such as `:read_only` with `:destructive`, are compile errors. ``` class Reports < AC::Base base "/reports" # a search that takes its query in the body, it doesn't change anything @[AC::MCP(behaviour: :read_only)] @[AC::Route::POST("/search", body: :query)] def search(query : ReportQuery) : Array(Report) Report.search(query) end # generating a report is recorded and emails the owner @[AC::MCP(behaviour: [:additive, :open_world])] @[AC::Route::GET("/:id/generate")] def generate(id : Int64) : Report Report.find!(id).generate! end end ``` | Behaviour | Hint sent to the host | | ------------------------------- | ----------------------------------------------------------------------- | | `:read_only` | `readOnlyHint: true`, otherwise `false` | | `:destructive` / `:additive` | `destructiveHint: true` / `false` | | `:idempotent` | `idempotentHint: true` | | `:open_world` / `:closed_world` | `openWorldHint: true` / `false`, whether it reaches outside your system | Clients that can't see opened tools run read only tools through `call_read_only`, which ChatGPT, for example, runs without asking the user to confirm. Everything else goes through `call_tool`. See [how agents see your API](https://spider-gazelle.net/mcp/#how-agents-see-your-api). Mark GET routes with side effects `call_read_only` runs any read only tool without the client asking for confirmation. If a GET route changes data, sends messages or runs commands, give it a `behaviour:`, such as `[:additive, :open_world]`. ## Visibility In hosts that support [UI cards](https://spider-gazelle.net/mcp/ui/index.md), `visibility:` controls who can call a tool: | Visibility | Model | Cards | | ------------------ | ------------------------- | ----- | | both (the default) | yes | yes | | `:card` | no, hidden from the model | yes | | `:model` | yes | no | Card only tools are root items by default, so cards can call them in any client, and the `call_tool` and `call_read_only` proxies refuse them. It isn't access control: a client can still call them by name, so protect them with filters like any other route. ## Titles Tools and prompts are named `_`. Give them a display name with `title:`, which hosts show instead: ``` @[AC::MCP(title: "Book a room")] @[AC::Route::POST("/")] def create(booking : Booking) : Booking ``` ## Icons Add icons with `@[AC::Icon]`, repeated for different sizes or themes: ``` @[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`:** `https:` and `data:` URLs are sent as is. A file in the [UI folder](https://spider-gazelle.net/mcp/ui/index.md) (`ui_base`) is sent as a `data:` URL. Anything else is a path on the current host, `https:///icons/book.svg`. - **Other arguments** (`sizes`, `theme`, `mimeType`) are passed through as is. - **On a controller,** icons are the default for its tools and prompts, and the icon of its toolbox and its [endpoint](https://spider-gazelle.net/mcp/endpoints/index.md) server. - **The server's icon:** `ActionController::MCPServer.icon "icons/logo.svg", sizes: ["any"]`. ## Upgrading from earlier releases These options were replaced in action-controller 8.14, and the old names are compile errors: | Before | Now | | ------------------------------------ | ------------------------------------------------------- | | `read_only: true` | `behaviour: :read_only` | | `read_only: false` | a `behaviour:` without `:read_only`, e.g. `[:additive]` | | `card_only: true` / `app_only: true` | `visibility: :card` | Regenerate `mcp.yml` after upgrading. ## See also - [MCP overview](https://spider-gazelle.net/mcp/index.md) - [Authentication](https://spider-gazelle.net/mcp/authentication/index.md) - [Filters](https://spider-gazelle.net/guides/filters/index.md) # Controller endpoints The global MCP server exposes your whole API as toolboxes. A controller can also be served as **its own MCP server**, scoped to one resource: a room, an account, a project. Give each user, team or integration the URL of the resource they work with, and the model only sees the tools for it. ``` # 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 ``` `ActionController::MCPServer.mount(server)` mounts it alongside the global server. A client connecting to `/rooms/boardroom/mcp` sees two tools, `state()` and `lights(on)`, and both run against the boardroom. ## How it works | | Global server | Controller endpoint | | ------------ | --------------------------------------------- | ----------------------------------------------------------------------------------- | | URL | `/mcp` (configurable) | `/mcp`, or `/sub/path` with `endpoint: "/sub/path"` | | Tools | toolboxes, opened on demand, plus the proxies | every route and prompt of the controller, listed directly | | Tool names | `_` | `` | | Instructions | `MCPServer.instructions` | the controller's doc comment, or its [`instructions` method](#dynamic-instructions) | | Server name | `MCPServer.server_name` | the controller's toolbox name, i.e. `room` | - **Bound path params:** path params in the base path (`:room_id`) are taken from the endpoint URL. They're removed from the tool arguments, so the model can't pick a different room, and a session can only be used at the URL it was created at. - **Nothing to open:** there are no toolboxes, meta tools or proxies, so it works with clients that ignore `tools/list_changed`. - **Visibility:** an endpoint controller is hidden from the global server. Annotate it `@[AC::MCP(endpoint: true, hide: false)]` to keep it there too. A method annotated `hide: true` is hidden from both. `behaviour:`, `visibility:`, `title:`, `ui:`, icons and `prompt: true` work as usual, and the controller's icons are the endpoint server's icon. - **Descriptions:** `write_description` includes the endpoints in `mcp.yml`. If a deployed `mcp.yml` is missing an endpoint, the description is regenerated without comments and a warning is logged. ## Dynamic instructions The instructions tell the model what it's working with. To build them for each session, for example from the room's name and features, define an `instructions` method on the endpoint controller: ``` @[AC::MCP(endpoint: true)] class Room < AC::Base base "/rooms/:room_id" # runs like a route when a client connects def instructions(room_id : String) : String room = RoomModel.find!(room_id) "You control #{room.name}, which has #{room.features.join(", ")}. Look up its state before changing it." end end ``` - **It runs like a route:** before filters run first, so authentication, `current_user` and resource lookups work, and path params can be arguments. - **It's internal:** it isn't an HTTP route, a tool or a prompt. - **Failures stop the connection:** if it doesn't succeed, for example a filter responds 403 or the room doesn't exist, `initialize` returns the error and no session is created. A 401 challenges the client to sign in again. - **It must return a `String`:** this is checked at compile time. Return an empty string for no instructions. Without the method, the controller's doc comment is used. ## Access control Endpoints share the `MCPServer` configuration: authentication (`auth_probe`, `resource_metadata`), forwarded headers, allowed origins and the [tool result](https://spider-gazelle.net/mcp/configuration/#tool-results) format. Each endpoint URL is its own OAuth protected resource: `/.well-known/oauth-protected-resource/rooms/boardroom/mcp` advertises `https://example.com/rooms/boardroom/mcp` as the resource. Tool calls run the controller's filters, so check that the user can access the resource in a `before_action`, as you would for the HTTP routes: ``` @[AC::Route::Filter(:before_action)] def check_room_access(room_id : String) raise AccessDenied.new unless current_user.can_access?(room_id) end ``` The URL isn't a credential Anyone who has the URL can connect, subject to authentication. Treat the bound params like any other user input and authorise every call. ## Dynamic capabilities The tools are fixed by the controller's routes, but their results can be as dynamic as you need. A common pattern for resources whose capabilities change at runtime is three tools: 1. `capabilities`: what this resource can do right now, plus context for the model. 1. `function_schema(capability)`: the functions a capability offers, with their JSON schemas. 1. `call_function(capability, function, params)`: runs one. The model discovers and calls functions through tool results, so it doesn't depend on the client refreshing its tool list. PlaceOS uses this for its per-system AI assistant endpoint. ## See also - [MCP overview](https://spider-gazelle.net/mcp/index.md) - [Annotation options](https://spider-gazelle.net/mcp/prompts/index.md) - [Authentication](https://spider-gazelle.net/mcp/authentication/index.md) - [Configuration](https://spider-gazelle.net/mcp/configuration/index.md) # UI cards Tools can render an interactive card in the conversation instead of just text. Cards use the official [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) extension, supported by Claude, ChatGPT, VS Code, Microsoft 365 Copilot and others. Clients without it show the tool's text result as usual. A card is a static HTML file. Point a route at it with `ui:`: ``` class Bookings < AC::Base base "/bookings" # Shows a booking @[AC::MCP(ui: "bookings/card.html")] @[AC::Route::GET("/:id")] def show(id : String) : Booking Booking.find!(id) end end ``` and tell the MCP server where the cards are: ``` ActionController::MCPServer.ui_base = "./cards" # cards/bookings/card.html ``` The template keeps its cards in `cards/`, next to `www/`. Set `SG_MCP_UI` to change it. ## How it works 1. The tool's definition points at the card, `ui://bookings/card.html`. 1. When the tool is called, the host reads the card with `resources/read` and renders it in a sandboxed iframe. 1. The host sends the card the tool's arguments, then its result. The result is the [`{status, headers, body}` envelope](https://spider-gazelle.net/mcp/configuration/#tool-results), as `structuredContent`. Cards can also call tools through the host, for example to refresh their data or act on a button press. ## Writing a card Cards are self-contained HTML5 documents that talk to the host with JSON-RPC over `postMessage`. You can use the `@modelcontextprotocol/ext-apps` SDK, or the protocol directly: ```

Loading…

``` The template's `cards/welcome/result.html` is a complete example: it follows the host's theme and reports its size so the host can fit the frame to it. - **Theme:** the `ui/initialize` result includes `hostContext.theme` (`light` or `dark`) and CSS variables such as `--color-text-primary` in `hostContext.styles.variables`. - **Size:** send `ui/notifications/size-changed` with the content height. - **Calling tools:** send a `tools/call` request, for example to refresh the card's data. ## Card settings By default hosts run cards with a strict content security policy: inline scripts and styles only, no external resources and no network requests. Declare anything a card needs with `ui_meta`, the default for every card: ``` ActionController::MCPServer.ui_meta = ActionController::MCPServer::UIMeta.new( prefers_border: true, csp: ActionController::MCPServer::UICSP.new(resource_domains: ["https://cdn.example.com"]), ) ``` or for a single card, with a `.meta.json` file next to it, which replaces the default: ``` // cards/bookings/card.meta.json {"csp": {"connectDomains": ["https://api.example.com"]}, "prefersBorder": false} ``` | Setting | Purpose | | --------------------- | ------------------------------------------------------------------------------- | | `csp.connectDomains` | origins the card can `fetch` from | | `csp.resourceDomains` | origins for scripts, styles, images, fonts and media | | `csp.frameDomains` | origins the card can embed | | `csp.baseUriDomains` | allowed base URIs | | `permissions` | browser permissions to request, e.g. `{"clipboardWrite": {}}`. Hosts may refuse | | `domain` | a dedicated sandbox origin, host specific | | `prefersBorder` | whether the host draws a border around the card | ## Tools only cards call Mark tools a card uses, but the model shouldn't, with `visibility: :card`. Hosts hide them from the model: ``` # Checks in to a booking @[AC::MCP(visibility: :card)] @[AC::Route::POST("/:id/check_in")] def check_in(id : String) : Booking ``` They're still ordinary routes, protected by your filters. The opposite, `visibility: :model`, stops cards calling a tool. Hosts enforce visibility, and the `call_tool` and `call_read_only` proxies refuse card only tools, but it isn't access control: a client can still call a tool by name. In the tool definition, `:card` is sent as the spec's `"app"` (`_meta.ui.visibility: ["app"]`). Card metadata is sent to every client. Hosts that support cards don't always advertise it, and hosts that don't support them ignore it and show the text result. ## Things to know - **Root by default:** hosts only render cards for, and let cards call, the tools in their tool list, and some clients don't refresh their tools when a toolbox opens. So tools with `ui:` or `visibility: :card` are root items, always listed, unless you annotate them `root: false`. - **Caching:** hosts cache cards by URI, so tools advertise `ui://bookings/card.html?v=`. Deploying a changed card changes its URI. - **Safety:** `ui:` paths must be relative `.html` paths (checked at compile time), and nothing outside `ui_base` is served. - **Deploying:** copy the cards folder into your image, like `www/`. ## See also - [MCP overview](https://spider-gazelle.net/mcp/index.md) - [Controller endpoints](https://spider-gazelle.net/mcp/endpoints/index.md) - [MCP Apps specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx) # Authentication By default your MCP endpoint is open, and each tool call is authenticated by your routes' own filters, using the headers the client sends. That's enough for many apps. This page covers the options, from API keys up to a complete OAuth sign-in for MCP clients, built with [multi_auth](https://github.com/msa7/multi_auth) and [authly](https://github.com/azutoolkit/authly). ## Choosing an approach | Approach | Use it when | Configuration | | ------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | **Route auth only** | the API is public, or every client can send a fixed header | nothing | | **API keys** | agents and scripts with long lived credentials | `auth_probe`, and the client sends a header | | **OAuth sign-in** | people connect from Claude, VS Code, Cursor, ... and sign in with their own account | `auth_probe` + `resource_metadata`, and an OAuth authorization server | Whichever you choose, tool calls run your real routes with the client's `Authorization`, `Cookie` and `X-API-Key` headers. Permissions are enforced exactly as they are for HTTP requests. Your MCP server can't do anything the user couldn't do with the API directly. ## Why MCP needs more than route auth MCP clients only sign in, or refresh an expired token, when the **MCP endpoint itself** responds with HTTP `401` and a `WWW-Authenticate` challenge. With route auth alone, connecting always succeeds, and a `401` from a route reaches the model as a failed tool call it can't fix. Enabling authentication moves the check to the endpoint: - **Every request is checked:** `initialize`, tool calls, and the notification stream. Failures get a real `401`. - **Token expiry is caught:** if a route rejects a tool call with `401` (an expired token, say), the endpoint answers `401` so the client refreshes and retries. - **Checks are cached:** successful checks are cached for each credential for `auth_cache_ttl` (1 minute), so your auth route isn't called on every message. ## API keys Point `auth_probe` at any route that requires authentication. It's called in-process with the client's headers, and a 2xx response means the request is authenticated: ``` ActionController::MCPServer.auth_probe = "/api/users/current" ``` Clients send their key on every request: ``` claude mcp add --transport http my-app https://my-app.example.com/mcp \ --header "X-API-Key: " ``` Need logic that isn't a route? Use `authenticator` instead: ``` ActionController::MCPServer.authenticator = ->(request : HTTP::Request) do ApiKey.valid?(request.headers["X-API-Key"]?) end ``` ## OAuth sign-in For people, OAuth is seamless: they add your server's URL to their client, a browser opens, they sign in and approve the client, and they're connected. Tokens refresh automatically. ### How it works MCP builds on standard OAuth: protected resource metadata ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728)), authorization server metadata ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)), dynamic client registration ([RFC 7591](https://www.rfc-editor.org/rfc/rfc7591)) and the authorization code flow with [PKCE](https://www.rfc-editor.org/rfc/rfc7636): ``` sequenceDiagram participant C as MCP client participant S as Your app (/mcp) participant A as Authorization server participant P as GitHub (via multi_auth) C->>S: initialize S-->>C: 401 + resource_metadata URL C->>S: GET /.well-known/oauth-protected-resource/mcp C->>A: GET /.well-known/oauth-authorization-server C->>A: POST /oauth/register (dynamic registration) C->>A: browser: /oauth/authorize (PKCE, resource) A->>P: not signed in: sign in with GitHub P-->>A: callback, user identified A-->>C: consent approved: redirect with code C->>A: POST /oauth/token (code + verifier) A-->>C: access + refresh tokens C->>S: initialize (Authorization: Bearer ...) ``` Spider-Gazelle provides the first half: the challenge and the protected resource metadata are served by `MCPServer` once you configure `resource_metadata`: ``` ActionController::MCPServer.tap do |mcp| mcp.auth_probe = "/api/users/current" mcp.resource_metadata = ->(request : HTTP::Request) do ActionController::MCPServer::ResourceMetadata.new( authorization_servers: ["https://#{request.hostname}"], scopes_supported: ["api"], ) end end ``` The authorization server is either an existing identity platform that supports dynamic client registration, or part of your app. The rest of this page builds one into your app: **multi_auth** answers "who is this person?" (GitHub, Google, and other providers) and **authly** answers "give this MCP client a token". ## Worked example: multi_auth + authly A small notes API that MCP clients sign in to with GitHub. It's one app that is both the **resource server** (the API and `/mcp`) and the **authorization server**. Tested This example was built and tested end to end with action-controller 8.3.2 (the MCP authentication options it uses are unchanged in 8.14), authly `master` (`3e96031`) and multi_auth `master` (`f1f60bf`). The test covered registration, sign-in, consent, PKCE token exchange, MCP calls with the token, refresh, and rejection of replayed codes, forged `state` and wrong verifiers. | File | Role | | ---------------------------------- | -------------------------------------------------------------------- | | `src/config.cr` | configures the session, authly, multi_auth and the MCP server | | `src/controllers/api.cr` | the protected API, verifying access tokens in a `before_action` | | `src/controllers/sessions.cr` | signs users in with multi_auth, checking `state` | | `src/controllers/authorization.cr` | metadata, registration, authorize (with consent) and token endpoints | | `src/oauth/*.cr` | client registry, token minting and the authly fixes | ### 1. Dependencies shard.yml ``` name: mcp-auth-example version: 0.1.0 targets: app: main: src/app.cr dependencies: action-controller: github: spider-gazelle/action-controller version: ~> 8.14 # OAuth 2 authorization server library authly: github: azutoolkit/authly branch: master # sign in with GitHub, Google, etc. master adds `state` to `authorize_uri` multi_auth: github: msa7/multi_auth branch: master ``` Use `master` for both shards multi_auth's tagged release can't pass a `state` to the provider, which you need for CSRF protection. The authly fixes below target `master`. ### 2. Configuration The app is its own issuer, and the MCP server advertises it in the protected resource metadata: src/config.cr ``` require "action-controller" require "action-controller/mcp" require "authly" require "multi_auth" require "multi_auth/providers/github" module App # the public URL of this app, it's both the MCP resource and the OAuth issuer URL = ENV["APP_URL"]? || "http://localhost:3000" # the scope MCP clients request SCOPE = "api" # DEVELOPMENT ONLY: adds a "dev" sign in that skips the identity provider DEV_LOGIN = ENV["DEV_LOGIN"]? == "1" # is this a resource indicator (RFC 8707) for this app? def self.resource?(resource : String) : Bool resource == URL || resource.starts_with?("#{URL}/") end end require "./models/*" require "./oauth/*" require "./controllers/application" require "./controllers/*" # the server is required after the controllers require "action-controller/server" ActionController::Server.before( ActionController::ErrorHandler.new(ENV["SG_ENV"]? == "production", ["X-Request-ID"]), ActionController::LogHandler.new(["code", "code_verifier", "refresh_token", "state"], ms: true) ) ActionController::Session.configure do |settings| settings.key = "_mcp_example_session" settings.secret = ENV["SESSION_SECRET"]? || Random::Secure.hex(64) settings.secure = App::URL.starts_with?("https://") end # the OAuth authorization server Authly.configure do |config| # HS256 signs and verifies with the same key jwt_secret = ENV["JWT_SECRET"]? || Random::Secure.hex(32) config.secret_key = jwt_secret config.public_key = jwt_secret config.issuer = App::URL config.code_ttl = 5.minutes config.access_ttl = 1.hour config.refresh_ttl = 30.days config.clients = AuthServer::CLIENTS end # users sign in with GitHub MultiAuth.config("github", ENV["GITHUB_CLIENT_ID"]? || "", ENV["GITHUB_CLIENT_SECRET"]? || "") # the MCP server challenges clients that don't present a valid access token ActionController::MCPServer.tap do |mcp| mcp.server_name = "notes" mcp.auth_probe = "/api/users/current" mcp.resource_metadata = ->(_request : HTTP::Request) do ActionController::MCPServer::ResourceMetadata.new( authorization_servers: [App::URL], scopes_supported: [App::SCOPE], ) end end ``` src/app.cr ``` require "./config" port = ENV["PORT"]?.try(&.to_i) || 3000 server = ActionController::Server.new(port, "127.0.0.1") ActionController::MCPServer.mount(server, "/mcp") Signal::INT.trap { server.close } server.run { puts "Listening on #{server.print_addresses}" } ``` ### 3. Signing in with multi_auth multi_auth handles the provider round trip. It doesn't validate the OAuth `state` parameter, so store a random value in the session and check it in the callback. That stops an attacker from logging a victim in to the attacker's account. src/controllers/sessions.cr ``` # signs users in with an identity provider, using multi_auth @[AC::MCP(hide: true)] class Sessions < Application base "/auth" PROVIDERS = ["github"] # the sign in page @[AC::Route::GET("/")] def index providers = App::DEV_LOGIN ? PROVIDERS + ["dev"] : PROVIDERS links = providers.map { |name| %(
  • Sign in with #{name}
  • ) } render html: page("Sign in", "
      #{links.join}
    ") end # redirects to the identity provider @[AC::Route::GET("/:provider")] def sign_in(provider : String) # DEVELOPMENT ONLY: sign in without an identity provider if provider == "dev" && App::DEV_LOGIN return signed_in User.save(User.new("dev:developer", "Developer")) end # multi_auth doesn't validate `state`, we check it in the callback state = Random::Secure.urlsafe_base64(32) session["oauth_state"] = state redirect_to engine(provider).authorize_uri(state: state), :see_other end # the identity provider redirects back here @[AC::Route::GET("/:provider/callback")] def callback(provider : String, state : String = "") expected = session.delete("oauth_state").to_s if expected.empty? || !Crypto::Subtle.constant_time_compare(expected, state) render :bad_request, text: "invalid sign in state, please try again" end engine = engine(provider) auth = begin engine.user(request.query_params) rescue error Log.warn(exception: error) { "#{provider} sign in failed" } render :unauthorized, text: "sign in failed" end signed_in User.from(auth) end private def engine(provider : String) : MultiAuth::Engine raise AuthServer::Error.new("invalid_request", "unknown provider", :not_found) unless provider.in?(PROVIDERS) MultiAuth.make(provider, "#{App::URL}/auth/#{provider}/callback") end # continues the OAuth authorization request that required sign in private def signed_in(user : User) session["user_id"] = user.id redirect_to session.delete("return_to").try(&.to_s) || "/auth", :see_other end end ``` src/models/user.cr ``` # a signed in person. Stored in memory for the example, use your database struct User include JSON::Serializable getter id : String getter name : String getter email : String? def initialize(@id, @name, @email = nil) end STORE = {} of String => User def self.find?(id : String) : User? STORE[id]? end def self.save(user : User) : User STORE[user.id] = user end # finds or creates the user for an identity provider account def self.from(auth : MultiAuth::User) : User save new("#{auth.provider}:#{auth.uid}", auth.name || auth.nickname || auth.uid, auth.email) end end ``` GitHub OAuth app Create an OAuth app in GitHub (**Settings → Developer settings → OAuth Apps**) and set its callback URL to `/auth/github/callback`. multi_auth's GitHub provider relies on the registered callback. Then set `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET`. ### 4. The authorization server These endpoints are what MCP clients talk to: - **Metadata:** `/.well-known/oauth-authorization-server`, where clients discover the endpoints. - **Registration:** `/oauth/register`. Every client is public (no secret), so PKCE proves possession. - **Authorize:** `/oauth/authorize`. It sends the user to sign in if needed, shows a consent page protected by a CSRF token, then issues a code. S256 PKCE is required. - **Token:** `/oauth/token`. Codes and refresh tokens are single use, and refresh tokens rotate. src/controllers/authorization.cr ``` # the OAuth 2 authorization server that MCP clients sign in with @[AC::MCP(hide: true)] class Authorization < Application base "/" alias Tokens = AuthServer::Tokens alias Error = AuthServer::Error CLIENTS = AuthServer::CLIENTS # authorization server metadata (RFC 8414), where MCP clients discover the endpoints @[AC::Route::GET("/.well-known/oauth-authorization-server")] def metadata { issuer: App::URL, authorization_endpoint: "#{App::URL}/oauth/authorize", token_endpoint: "#{App::URL}/oauth/token", registration_endpoint: "#{App::URL}/oauth/register", scopes_supported: [App::SCOPE], response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token"], token_endpoint_auth_methods_supported: ["none"], code_challenge_methods_supported: ["S256"], } end # dynamic client registration (RFC 7591) @[AC::Route::POST("/oauth/register", body: :client_metadata, status_code: HTTP::Status::CREATED)] def register(client_metadata : Hash(String, JSON::Any)) : Authly::DynamicClient CLIENTS.register(client_metadata) end # the user signs in and approves the client, which receives an authorization code @[AC::Route::GET("/oauth/authorize")] @[AC::Route::POST("/oauth/authorize")] def authorize( response_type : String, client_id : String, redirect_uri : String, code_challenge : String = "", code_challenge_method : String = "", scope : String = "", state : String = "", resource : String = "", decision : String = "", csrf_token : String = "", ) # errors are never redirected to an unverified redirect_uri client = CLIENTS.find(client_id) raise Error.new("invalid_request", "unknown client or redirect_uri") unless client && client.redirect_uris.includes?(redirect_uri) raise Error.new("unsupported_response_type") unless response_type == "code" raise Error.new("invalid_request", "PKCE with S256 is required") if code_challenge.empty? || code_challenge_method != "S256" raise Error.new("invalid_target") unless resource.empty? || App.resource?(resource) scope = scope.presence || App::SCOPE raise Error.new("invalid_scope") unless CLIENTS.allowed_scopes?(client_id, scope) # sign in first, then return here unless user = session_user session["return_to"] = request.resource redirect_to "/auth", :see_other end # ask the user to approve the client csrf = (session["csrf_token"] ||= Random::Secure.urlsafe_base64(32)).to_s unless request.method == "POST" && decision.in?("allow", "deny") && Crypto::Subtle.constant_time_compare(csrf, csrf_token) fields = {client_id: client_id, redirect_uri: redirect_uri, response_type: response_type, code_challenge: code_challenge, code_challenge_method: code_challenge_method, scope: scope, state: state, resource: resource, csrf_token: csrf} response.headers["Content-Security-Policy"] = "frame-ancestors 'none'" render html: consent_page(client, user, fields) end redirect_to with_params(redirect_uri, error: "access_denied", state: state) if decision == "deny" code = Authly.code("code", client_id, redirect_uri, scope, code_challenge, code_challenge_method, user.id).as(Authly::Code) redirect_to with_params(redirect_uri, code: code.to_s, state: state) end # exchanges an authorization code or refresh token for tokens @[AC::Route::POST("/oauth/token")] def token( grant_type : String, client_id : String, code : String = "", redirect_uri : String = "", code_verifier : String = "", refresh_token : String = "", resource : String = "", ) : Tokens::Response response.headers["Cache-Control"] = "no-store" raise Error.new("invalid_target") unless resource.empty? || App.resource?(resource) case grant_type when "authorization_code" # authly validates the code, client, redirect_uri and PKCE verifier Authly::AuthorizationCode.new(client_id, "", redirect_uri, code, code_verifier).authorized? claims = Authly.jwt_decode(code).first raise Error.new("invalid_grant") unless claims["client_id"]? == client_id Tokens.spend!(claims["jti"].as_s) Tokens.issue(claims["user_id"].as_s, client_id, claims["scope"].as_s) when "refresh_token" # authly validates the token signature, expiry and the client Authly::RefreshToken.new(client_id, "", refresh_token).authorized? claims = Authly.jwt_decode(refresh_token).first raise Error.new("invalid_grant") unless claims["typ"]? == "refresh" && claims["cid"]? == client_id Tokens.spend!(claims["jti"].as_s) # refresh tokens rotate Tokens.issue(claims["sub"].as_s, client_id, claims["scope"].as_s) else raise Error.new("unsupported_grant_type") end rescue error : Authly::Error raise Error.new(error.type.to_s, error.message, HTTP::Status.new(error.code)) end private def consent_page(client : Authly::DynamicClient, user : User, fields) : String hidden = fields.map { |key, value| %() }.join page("Allow access?", <<-HTML)

    Signed in as #{HTML.escape(user.name)}.

    #{HTML.escape(client.client_name || client.client_id)} wants to use your account (scope: #{HTML.escape(fields[:scope])}) and will return to #{HTML.escape(fields[:redirect_uri])}

    #{hidden}
    HTML end private def with_params(uri : String, **params) : String uri = URI.parse(uri) query = uri.query_params params.each { |key, value| query[key.to_s] = value unless value.empty? } uri.query_params = query uri.to_s end end ``` src/oauth/clients.cr ``` module AuthServer # OAuth clients, registered by MCP clients using dynamic client registration (RFC 7591). # Authly calls these methods when validating authorization and token requests. class Clients include Authly::AuthorizableClient # authly's device flow handler, which we don't use, needs `any?` to compile include Enumerable(Authly::Client) def each(& : Authly::Client ->) : Nil end GRANT_TYPES = {"authorization_code", "refresh_token"} # every client is public: they can't keep a secret, so PKCE proves the # code is being redeemed by the client that requested it def register(metadata : Hash(String, JSON::Any)) : Authly::DynamicClient metadata["token_endpoint_auth_method"] = JSON::Any.new("none") client = begin Authly::DynamicClient.from_registration_request(metadata) rescue error raise Error.new("invalid_client_metadata", error.message) end raise Error.new("invalid_client_metadata") unless client.valid? && supported?(client) Authly.config.client_store.store(client) client end def find(client_id : String) : Authly::DynamicClient? Authly.config.client_store.fetch(client_id) end def valid_redirect?(client_id : String, redirect_uri : String) : Bool !!find(client_id).try(&.redirect_uris.includes?(redirect_uri)) end # public clients have no secret def authorized?(client_id : String, client_secret : String) !find(client_id).nil? end def allowed_scopes?(client_id : String, scopes : String) : Bool scopes.split.all?(App::SCOPE) end def allowed_grant_type?(client_id : String, grant_type : String) : Bool !!find(client_id).try(&.grant_types.includes?(grant_type)) end private def supported?(client) : Bool client.grant_types.all?(&.in?(GRANT_TYPES)) && client.response_types == ["code"] && client.redirect_uris.all? { |uri| safe_redirect?(URI.parse(uri)) } end # https, or http on the loopback interface for native apps (RFC 8252) private def safe_redirect?(uri : URI) : Bool return false if uri.fragment uri.scheme == "https" || (uri.scheme == "http" && uri.host.in?("localhost", "127.0.0.1", "[::1]")) end end CLIENTS = Clients.new end ``` src/oauth/tokens.cr ``` module AuthServer # access and refresh tokens are JWTs signed by authly. # We mint them here as authly's `AccessToken` doesn't record the user (`sub`) module Tokens extend self # the token endpoint response (RFC 6749 section 5.1) struct Response include JSON::Serializable getter access_token : String getter token_type : String = "Bearer" getter expires_in : Int64 getter refresh_token : String getter scope : String def initialize(@access_token, @expires_in, @refresh_token, @scope) end end def issue(user_id : String, client_id : String, scope : String) : Response config = Authly.config Response.new( access_token: encode("access", user_id, client_id, scope, config.access_ttl), expires_in: config.access_ttl.total_seconds.to_i64, refresh_token: encode("refresh", user_id, client_id, scope, config.refresh_ttl), scope: scope, ) end # returns the claims of a valid access token issued for this app def verify(token : String) : JSON::Any? claims = Authly.jwt_decode(token).first claims if claims["typ"]? == "access" && claims["aud"]? == App::URL rescue JWT::Error nil end # authorization codes and refresh tokens can only be used once def spend!(jti : String) : Nil store = Authly.config.token_store raise Error.new("invalid_grant", "already used") if store.revoked?(jti) store.revoke(jti) end private def encode(type : String, user_id, client_id, scope, ttl : Time::Span) : String now = Time.utc Authly.jwt_encode({ "typ" => type, "iss" => Authly.config.issuer, "aud" => App::URL, "sub" => user_id, "cid" => client_id, "scope" => scope, "jti" => Random::Secure.hex(16), "iat" => now.to_unix, "exp" => (now + ttl).to_unix, }) end end end ``` src/oauth/error.cr ``` module AuthServer # an OAuth error response (RFC 6749 section 5.2) class Error < Exception getter error : String getter status : HTTP::Status def initialize(@error, description : String? = nil, @status = HTTP::Status::BAD_REQUEST) super(description || error) end end end ``` ### 5. The protected API A `before_action` verifies the access token, so every route is protected, and so is every MCP tool call, because tool calls run the same filters. `auth_probe` points at `/api/users/current`, so the MCP endpoint uses exactly the same check. src/controllers/api.cr ``` # the protected API, every request requires an access token from our authorization server abstract class Api < Application getter! user : User @[AC::Route::Filter(:before_action)] def authenticate header = request.headers["Authorization"]? || "" claims = AuthServer::Tokens.verify(header.lchop("Bearer ")) if header.starts_with?("Bearer ") @user = claims.try { |token| User.find?(token["sub"].as_s) } render :unauthorized, json: {error: "invalid_token"} unless @user end end # the signed in user class Users < Api base "/api/users" # returns the user the access token was issued to @[AC::MCP(root: true)] @[AC::Route::GET("/current")] def current : User user end end # your notes class Notes < Api base "/api/notes" NOTES = Hash(String, Array(String)).new { |hash, key| hash[key] = [] of String } # lists your notes @[AC::Route::GET("/")] def index : Array(String) NOTES[user.id] end # saves a note @[AC::Route::POST("/", status_code: HTTP::Status::CREATED)] def create( @[AC::Param::Info(description: "the text of the note")] text : String, ) : Array(String) NOTES[user.id] << text end end ``` src/controllers/application.cr ``` abstract class Application < ActionController::Base # the user signed in to this browser session def session_user : User? session["user_id"]?.try { |id| User.find?(id.to_s) } end @[AC::Route::Exception(AuthServer::Error)] def oauth_error(error) render status: error.status, json: {error: error.error, error_description: error.message} end protected def page(title : String, body : String) : String <<-HTML #{title}

    #{title}

    #{body} HTML end end ``` ### 6. authly fixes At the time of writing, authly `master` needs three fixes to be secure for this flow. Include this file, and remove each fix once it's fixed upstream: src/oauth/authly_patches.cr ``` require "base64" require "digest/sha256" # Fixes for authly (azutoolkit/authly master), remove them once fixed upstream. module Authly struct Code # upstream reads the issuer and TTL from constants captured when authly is # required, before `Authly.configure` runs, so codes failed `jwt_decode`. # We also bind the code to its client. def jwt Authly.jwt_encode({ "jti" => Random::Secure.hex(32), "code" => code, "client_id" => client_id, "challenge" => challenge, "method" => method, "scope" => scope, "user_id" => user_id, "redirect_uri" => redirect_uri, "iat" => Time.utc.to_unix, "iss" => Authly.config.issuer, "exp" => Authly.config.code_ttl.from_now.to_unix, }) end end struct CodeChallengeBuilder::S256 # RFC 7636: BASE64URL(SHA256(verifier)) without padding. # Upstream compares against standard, padded base64 def valid?(code_verifier) code == Base64.urlsafe_encode(Digest::SHA256.digest(code_verifier), padding: false) end end class AuthorizationCode # upstream skips the PKCE check when the client omits `code_verifier`. # We require S256 PKCE for every code private def verify_challenge! valid = method == "S256" && !verifier.empty? && code_challenge.valid?(verifier) raise Error.invalid_grant unless valid end end end ``` | Problem | Effect | | ----------------------------------------------------------- | -------------------------------------------------- | | issuer and code TTL captured before `Authly.configure` runs | every code fails to decode | | S256 challenge compared as padded, standard base64 | no standards-compliant client can redeem a code | | PKCE skipped when `code_verifier` is omitted | a stolen code can be redeemed without the verifier | Other things to know: - **Don't use `Authly::Handler`:** its authorize handler takes the user from the query string. - **Mint your own tokens:** authly's own access tokens don't identify the user, so `AuthServer::Tokens` mints JWTs with `sub`, `aud` and a `typ` claim. - **Use one HS256 key:** the HS256 `secret_key` and `public_key` must be the same value. - **Convert authly errors:** authly raises the generic `Authly::Error(Code)`. The token action rescues it and re-raises it as `AuthServer::Error` with the error's own status code. An `@[AC::Route::Exception(Authly::Error)]` handler works too (action-controller 8.3.3+), but its status code is fixed. - **Annotate each controller:** controller-level `@[AC::MCP(hide: true)]` isn't inherited, so annotate each controller you want hidden. ### 7. Try it ``` shards install DEV_LOGIN=1 crystal run src/app.cr ``` Then connect a client: ``` claude mcp add --transport http notes http://localhost:3000/mcp ``` A browser opens to sign in. `DEV_LOGIN=1` adds a "Sign in with dev" option that skips GitHub during development. **Never set it in production.** After approving the client, the model starts with `users_current` (a root tool), and can open the `notes` toolbox to list and create notes as you. ## Production checklist The example keeps everything in memory, to stay readable. Before production: - **Persist** users, clients, spent codes and refresh tokens in a database or Redis (with TTLs), so restarts and multiple instances work. MCP sessions also need sticky routing when you run more than one instance. - **Load secrets** (`JWT_SECRET`, `SESSION_SECRET`) from your secret store, and prefer RS256/ES256 keys with rotation and a JWKS endpoint if other services verify tokens. - **Accept any loopback port** for `http://127.0.0.1` and `localhost` redirect URIs ([RFC 8252 §7.3](https://www.rfc-editor.org/rfc/rfc8252#section-7.3)). Native MCP clients choose their callback port at runtime. - **Support [client ID metadata documents](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)** (MCP's preferred registration method), and rate limit dynamic registration and clean up unused clients. - **Remember consent** per user and client, with a page to review and revoke access. - **Add revocation**, CORS on the metadata, registration and token endpoints (for browser-based clients such as the MCP Inspector), and rate limits on authorize and token. - **Generate `mcp.yml`** at build time so the model sees your tool descriptions. - **Serve over HTTPS**, and remove the `DEV_LOGIN` shortcut. A production reference [PlaceOS auth](https://github.com/PlaceOS) is a production authorization server built on the same pieces (authly + multi_auth). It adds client ID metadata documents, dynamic registration with rate limits, loopback port matching, consent, PKCE enforcement and RFC 8707 resource checks. [PlaceOS REST API](https://github.com/PlaceOS/rest-api) shows the resource server side, with `auth_probe` and `resource_metadata`. ## See also - [Configuration reference](https://spider-gazelle.net/mcp/configuration/index.md) - [Filters](https://spider-gazelle.net/guides/filters/index.md) - [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) # MCP configuration reference All options are class properties on `ActionController::MCPServer`, usually set in `src/config.cr`: ``` ActionController::MCPServer.tap do |mcp| mcp.server_name = "my-app" mcp.server_version = "1.0.0" # describe your domain, then how to use toolboxes (which explains the proxies when enabled) mcp.instructions = "Manages meeting rooms and bookings.\n\n#{mcp.toolbox_instructions}" end ``` ## Server | Option | Default | Purpose | | --------------------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `server_name` | `"action-controller"` | reported to clients on `initialize` | | `server_version` | `"1.0.0"` | reported to clients on `initialize` | | `instructions` | `nil` (`toolbox_instructions`) | text given to the model on `initialize`. Describe your domain here and append `toolbox_instructions`. `""` sends none | | `ui_base` | `nil` | the folder of [UI cards](https://spider-gazelle.net/mcp/ui/index.md), `ui://` paths resolve against it | | `ui_meta` | `nil` | the default card settings (`UIMeta`: CSP domains, permissions, border) | | `tool_proxy` | `true` | adds the `call_read_only` and `call_tool` meta tools, for clients that ignore `tools/list_changed` | | `description_path` | `"mcp.yml"` | the generated tool descriptions, relative to the working directory | | `session_timeout` | `30.minutes` | idle sessions are discarded | | `allowed_origins` | `[]` | browser origins allowed in addition to same-origin. `"*"` allows any | | `forward_headers` | `["Authorization", "Cookie", "X-API-Key"]` | request headers copied onto tool calls and prompts | | `excluded_response_headers` | noise, credentials, transport and browser policy headers | response headers left out of [tool results](#tool-results), matched case-insensitively. A trailing `*` matches a prefix | ## Authentication Authentication is optional and off by default. It's enabled when `auth_probe`, `authenticator` or `resource_metadata` is set. See [Authentication](https://spider-gazelle.net/mcp/authentication/index.md). | Option | Default | Purpose | | ------------------- | ---------- | ------------------------------------------------------------------------------------------- | | `auth_probe` | `nil` | a route requested in-process with the forwarded headers. A 2xx response means authenticated | | `authenticator` | `nil` | `Proc(HTTP::Request, Bool)`, a custom check used instead of the probe | | `auth_cache_ttl` | `1.minute` | how long a successful check is cached for each credential | | `resource_metadata` | `nil` | `Proc(HTTP::Request, ResourceMetadata)`, which advertises your OAuth authorization server | ## Methods | Method | Purpose | | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `mount(router, path = "/mcp", endpoints = true)` | registers the endpoint (and the protected resource metadata), plus the [controller endpoints](https://spider-gazelle.net/mcp/endpoints/index.md) | | `mount_endpoints(router)` | registers just the [controller endpoints](https://spider-gazelle.net/mcp/endpoints/index.md) | | `endpoint_paths` | the path templates of the controller endpoints | | `icon(src, **fields)` | adds an icon for the global server, see [icons](https://spider-gazelle.net/mcp/prompts/#icons) | | `toolbox_instructions` | the toolbox usage instructions, to append to your own `instructions` | | `write_description(path)` | generates `mcp.yml` from the code. Needs the source code | | `generate_description` | the same, returning the `Description` | | `description=` | replaces the loaded description (`nil` reloads it), useful in specs | ## Transport details - **Transport:** Streamable HTTP, for protocol versions `2025-11-25`, `2025-06-18` and `2025-03-26`. - **`POST`:** returns `application/json`. When a request produces notifications (e.g. opening a toolbox) and the client accepts `text/event-stream`, the notifications are streamed ahead of the result. - **`GET`:** with `Accept: text/event-stream`, opens a stream for server notifications. - **`DELETE`:** ends the session. - **Sessions:** identified by the `Mcp-Session-Id` header and stored in memory for each process. Deployments with multiple instances need sticky sessions. - **`Origin` headers:** must be same-origin or listed in `allowed_origins`, which protects against DNS rebinding. - **Unhandled exceptions:** are logged and returned as a generic `500` tool error. `Server.before` handlers (such as `ErrorHandler`) don't run for in-process tool calls. ## Tool results A tool call returns the route's response as `{status, headers, body}`. The same JSON is the text content (which most clients give the model) and `structuredContent`: ``` { "status": 200, "headers": {"X-Total-Count": "120", "Link": "; rel=\"next\""}, "body": [{"id": 1, "name": "Steve"}] } ``` - **`status`:** the HTTP status code. - **`headers`:** the response headers, minus those matching `excluded_response_headers`. Left out when there are none. This is how the model sees pagination (`Link`, `X-Total-Count`, `Content-Range`), `Location` after a create, `Retry-After` and `ETag`. - **`body`:** the parsed JSON, or the text of any other response. Left out when empty. - **Errors:** a status of 400 or above sets `isError`, and the error body is returned in the same shape. A `401` when authentication is enabled challenges the client to sign in again instead. - **Images and audio:** returned as an `image` or `audio` content block, followed by the envelope without a body. By default these headers are excluded: | Kind | Headers | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | Noise | `Date`, `Content-Length`, `X-Request-ID`, `Content-Type`, `Server`, `Vary`, `Cache-Control`, `Pragma`, `Expires`, `Alt-Svc` | | Credentials | `Set-Cookie`, `Cookie`, `Authorization`, `WWW-Authenticate`, `Proxy-*` | | Transport | `Connection`, `Keep-Alive`, `Transfer-Encoding`, `Content-Encoding`, `Trailer`, `Upgrade` | | Browser policy | `Strict-Transport-Security`, `Content-Security-Policy`, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Access-Control-*` | Add to the list, rather than replacing it, so credentials stay excluded: ``` ActionController::MCPServer.excluded_response_headers += ["X-Runtime", "X-Internal-*"] ``` ## See also - [MCP overview](https://spider-gazelle.net/mcp/index.md) - [Setup](https://spider-gazelle.net/mcp/setup/index.md) - [action-controller README](https://github.com/spider-gazelle/action-controller#mcp-server) # Deployment # Deployment A Spider-Gazelle app compiles to a single binary, so deploying it is mostly a matter of building that binary and running it. This page describes the Docker setup in the [application template](https://github.com/spider-gazelle/spider-gazelle), which builds a small image containing only your binary and the files it needs. Docker isn't required, the same steps apply to any server. ## Quick start From your project root: ``` docker build -t my-app . docker run --rm -p 3000:3000 -e SG_ENV=production -e COOKIE_SESSION_SECRET="$(openssl rand -hex 16)" my-app ``` Then open . ## The Dockerfile The template's `Dockerfile` is a multi-stage build. **Build stage** (`84codes/crystal:latest-alpine`): 1. Creates an unprivileged `appuser` (UID `10001`) to run the app. 1. Installs the build dependencies, then copies `shard.yml`, `shard.override.yml` and `shard.lock` and runs `shards install --production`. Dependencies are copied before your source, so Docker caches them until they change. 1. Copies `src/` and builds an optimised binary with `shards build --production --release`. 1. Collects the shared libraries the binary links against. 1. Generates the API descriptions while the source code is still available: ``` RUN ./bin/app --docs --file=openapi.yml && \ ./bin/app --mcp=mcp.yml ``` **Final stage** (`FROM scratch`, an empty image) contains only: - the binary at `/app`, and its shared libraries - `openapi.yml` and `mcp.yml` - CA certificates, so the app can call HTTPS services - timezone data, and the user and group files for `appuser` The final image has no shell, package manager or compiler, which keeps it small and reduces what an attacker could use. ### Why openapi.yml and mcp.yml are generated at build time The [OpenAPI document](https://spider-gazelle.net/openapi/index.md) and the [MCP tool descriptions](https://spider-gazelle.net/mcp/index.md) are both built from the doc comments in your source code. They're extracted with `crystal docs`, which needs the compiler and the source, and neither is in the final image. So the build stage generates the files and the final stage ships them next to the binary. At runtime: - The template serves `openapi.yml` at `GET /openapi`. - The MCP server loads `mcp.yml` the first time a client connects. Its location is set by `SG_MCP_DESCRIPTION`, relative to the working directory (`/` in the image). If the file is missing, the tools still work, but without descriptions. Tip Because routes, OpenAPI operations and MCP tools all come from the same annotated methods, every image you build ships documentation that matches its code. ## Running the container ``` docker run -d \ --name my-app \ --restart unless-stopped \ -p 8080:3000 \ -e SG_ENV=production \ -e COOKIE_SESSION_SECRET=your-32-character-or-longer-secret \ my-app ``` - `-d` runs the container in the background. - `--restart unless-stopped` restarts it after a crash or a reboot. - `-p 8080:3000` maps port 8080 on the host to port 3000 in the container. - `-e` sets [environment variables](https://spider-gazelle.net/getting_started/configuration/#environment-variables). The image's default command binds to `0.0.0.0` on port 3000: ``` ENTRYPOINT ["/app"] CMD ["-b", "0.0.0.0", "-p", "3000"] ``` Arguments after the image name replace `CMD` and are passed to `/app`. Include `-b 0.0.0.0` when you do this. Without it the app binds to `127.0.0.1` inside the container and can't be reached: ``` docker run --rm -p 3000:3000 my-app -b 0.0.0.0 -p 3000 -w 4 docker run --rm my-app --routes ``` Manage the container with `docker logs -f my-app`, `docker restart my-app`, `docker stop my-app` and `docker rm my-app`. ### Docker Compose The template includes a `docker-compose.yml` along these lines: ``` services: sg: build: . ports: - "3000:3000" environment: SG_ENV: "production" ``` Run `docker compose up -d --build` to build and start it. ## Environment variables Configure the container with environment variables rather than rebuilding it. The ones you'll usually set in production: | Variable | Why | | ----------------------- | -------------------------------------------------------------------------------------- | | `SG_ENV=production` | Production logging, no exception details in error responses, `Secure` session cookies. | | `COOKIE_SESSION_SECRET` | The template's default is public. Set your own, at least 32 bytes. | | `COOKIE_SESSION_KEY` | Optional, the session cookie name. | | `SG_MCP_PATH` | Optional. Set to an empty string to turn off the MCP endpoint. | See [Configuration](https://spider-gazelle.net/getting_started/configuration/#environment-variables) for the full list. Pass secrets through your platform's secret store, not on the command line in shared environments. ## Health checks The binary can health check itself. `-c URL` requests the URL with Crystal's built-in HTTP client, so a `scratch` image doesn't need `curl`: ``` HEALTHCHECK CMD ["/app", "-c", "http://127.0.0.1:3000/"] ``` It exits `0` for a response status from 200 to 499, `1` for any other status and `2` if the request fails. Docker marks the container `healthy` when the command exits `0` and `unhealthy` after repeated failures, and `docker ps` shows the status. - Point it at a cheap route that doesn't need authentication. - If you change the port, change the health check URL too. - Orchestrators that run commands, such as Kubernetes exec probes or ECS health checks, can use the same command: ``` livenessProbe: exec: command: ["/app", "-c", "http://127.0.0.1:3000/"] ``` ## Workers and scaling A Crystal process handles many requests concurrently. To use more CPU cores, run more threads with `-w` (or `SG_WORKER_COUNT`): ``` docker run -p 3000:3000 my-app -b 0.0.0.0 -p 3000 -w 4 docker run -p 3000:3000 -e SG_WORKER_COUNT=4 my-app ``` `-w 0` uses one thread per CPU core. See [Workers and threads](https://spider-gazelle.net/getting_started/configuration/#workers-and-threads). When you run several containers behind a load balancer: - Session cookies work on any instance, as long as they share the same `COOKIE_SESSION_SECRET`. - [WebSocket](https://spider-gazelle.net/guides/websockets/index.md) connections and MCP sessions live in the process that accepted them. MCP clients need sticky sessions, see [MCP](https://spider-gazelle.net/mcp/index.md). ## Pushing to a registry Tag the image with your registry's address, log in and push. For the GitHub Container Registry: ``` docker build -t ghcr.io/my-org/my-app:1.0.0 . echo "$GITHUB_TOKEN" | docker login ghcr.io -u my-username --password-stdin docker push ghcr.io/my-org/my-app:1.0.0 ``` On the server, `docker pull ghcr.io/my-org/my-app:1.0.0`, then run it as above. Docker Hub and cloud registries work the same way with their own address. ## GitHub Actions The template's `.github/workflows/ci.yml` runs the formatter and specs on every push, see [Testing](https://spider-gazelle.net/guides/testing/#continuous-integration). To build and publish an image as well, add a workflow such as this one. It isn't part of the template. It pushes to the GitHub Container Registry on every push to `main` and every `v*` tag: ``` # .github/workflows/docker.yml name: Docker on: push: branches: [main] tags: ["v*"] jobs: publish: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository }} - uses: docker/build-push-action@v6 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max ``` The image build runs `shards build`, but not your specs. Run the CI workflow first, or add a `needs:` dependency on its job, so failing code isn't published. ## Without Docker Build on a machine with the same OS and architecture as your server. The binary links against shared libraries such as OpenSSL and PCRE2, so the server needs them installed too. `ldd bin/app` lists them. ``` shards build --production --release ./bin/app --docs --file=openapi.yml ./bin/app --mcp=mcp.yml ``` Copy `bin/app`, `openapi.yml` and `mcp.yml` to the server, and run the binary from the folder containing the two YAML files: ``` SG_ENV=production ./app -b 0.0.0.0 -p 3000 ``` Run it under a process supervisor such as systemd, so it restarts on failure. The app shuts down gracefully on `SIGTERM`. ## See also - [Configuration](https://spider-gazelle.net/getting_started/configuration/index.md) - [Logging](https://spider-gazelle.net/guides/logging/index.md) - [Testing](https://spider-gazelle.net/guides/testing/index.md) - [OpenAPI](https://spider-gazelle.net/openapi/index.md) - [MCP](https://spider-gazelle.net/mcp/index.md) # AI agents # Agent reference A dense, self-contained summary of Spider-Gazelle (action-controller ~> 8.14) for AI coding agents and experienced developers. Paste it into an agent's context, or point the agent at [`/llms.txt`](https://spider-gazelle.net/llms.txt) (an index of the site) or [`/llms-full.txt`](https://spider-gazelle.net/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 it `App::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 with `crystal spec`, and format with `crystal 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 by `Accept`. - A parameter is required unless it's nilable or has a default. - Built-in conversions: numbers, `String`, `Char`, `Bool`, `Time`, `Enum`, `UUID`, and unions. `Bool` is `true` only for `true` (any case); any other value is `false`. Arrays need a custom converter. - `@[AC::Param::Info(description:, example:, name:, header:, class:, config:)]` sits on an argument. There's **no `required:`**. - 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 a `500`; the template maps them to `422` and `400`. - `rescue_from Klass, :method` also works, but its handler must `render` itself and it adds nothing to OpenAPI. Prefer the annotation. - `render` and `head` short-circuit an action or filter, e.g. `head :unauthorized`. - Filters can take typed params, which also appear in OpenAPI and MCP. - `force_tls` (alias `force_ssl`) redirects plain HTTP to HTTPS. Without `only:`/`except:` it covers every route. Proxy headers decide the protocol, otherwise a connection to a port `Server` bound with TLS is HTTPS. ## Sessions and cookies - `session["key"] = value` stores `String`, `Int64`, `Float64` or `Bool` in an encrypted cookie; reads return that union, so use `.as(Int64?)` etc. - `cookies` holds the cookies the client **sent**. Set or delete cookies on `response.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_yaml` on it. It's OpenAPI 3.1 (JSON Schema 2020-12 schemas) by default; pass `openapi: "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`. `OPTIONS` routes aren't documented either. - Every `JSON::Serializable` type and enum is one component, `$ref`'d wherever it's used (nested too). Nilable refs are `anyOf` with `{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 (`_without_` for the shorter ones). `Param::Info` examples 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)] # available without opening the 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:///src @[AC::MCP(endpoint: true)] # class only: also its own MCP server at /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 (root by default); :model = cards can't @[AC::MCP(prompt: true)] # a prompt; must return String or Array(AC::PromptMessage) ``` - Controllers are toolboxes and each route method is one tool named `_`. The module namespace shared by all controllers is left out of the names. - WebSocket, `OPTIONS` and DSL routes aren't exposed as tools. - Tool calls run the real route in-process, with the same filters and the same auth. `Authorization`, `Cookie` and `X-API-Key` are forwarded. - Auth is optional: `auth_probe = "/users/current"` (or a custom `authenticator`), plus `resource_metadata` for OAuth. - Meta tools: `list_toolboxes`, `open_toolbox` (returns tool definitions), `close_toolbox` and two proxies for clients that ignore `tools/list_changed`: `call_read_only` (read only tools, `readOnlyHint: true`) and `call_tool` (any tool the model can see; `visibility: :card` tools are refused by both). `tool_proxy = false` disables them. Read only = GET unless overridden with `behaviour:`. Custom `instructions` should append `MCPServer.toolbox_instructions`. - Tool results are `{status, headers, body}` (text and `structuredContent`). Headers matching `excluded_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](https://spider-gazelle.net/guides/routing/index.md), [Parameters](https://spider-gazelle.net/guides/parameters/index.md), [Responses](https://spider-gazelle.net/guides/responses/index.md), [Filters](https://spider-gazelle.net/guides/filters/index.md) and [Errors](https://spider-gazelle.net/guides/errors/index.md) for the full rules behind this summary - [OpenAPI](https://spider-gazelle.net/openapi/index.md) and [MCP](https://spider-gazelle.net/mcp/index.md) - [Configuration](https://spider-gazelle.net/getting_started/configuration/index.md) for the template's CLI flags and environment variables