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 |
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 |
ui: "path.html" |
controllers or methods | renders an HTML card for the tool's results, see UI cards |
resource: "app://widgets/{id}" |
GET methods | also exposes the route as a resource template, see resources |
mime_type:, cache_ttl:, subscribe: true |
resource methods | the contents type, how long reads may be cached, and whether clients can subscribe |
long_running: true, max_bytes: |
methods | spools the response to disk and streams progress, see long running routes |
Tools with ui: or visibility: :card are root items unless annotated root: false.
Icons have their own annotation, see 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 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) orArray(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_actionauthentication, 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
conversation. 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 <toolbox>_<method> 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 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 |
The model runs read only toolbox 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.
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, 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 <toolbox>_<method>. 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:anddata:URLs are sent as is. A file in the UI folder (ui_base) is sent as adata:URL. Anything else is a path on the current host,https://<host>/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 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.