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) |
<base>/mcp, or endpoint: "/sub/path" |
| Tools | toolboxes, opened on demand, plus the proxies | every route and prompt of the controller, listed directly |
| Tool names | <toolbox>_<method> |
<method> |
| Instructions | MCPServer.instructions |
the controller's doc comment |
| 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 annotatedhide: trueis hidden from both.read_only:andprompt: truework as usual. - Descriptions:
write_descriptionincludes the endpoints inmcp.yml. If a deployedmcp.ymlis missing an endpoint, the description is regenerated without comments and a warning is logged.
Access control#
Endpoints share the MCPServer configuration: authentication (auth_probe,
resource_metadata), forwarded headers, allowed origins and the
tool result 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:
capabilities: what this resource can do right now, plus context for the model.function_schema(capability): the functions a capability offers, with their JSON schemas.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.