Skip to content

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 annotated hide: true is hidden from both. read_only: and prompt: true work as usual.
  • 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.

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:

  1. capabilities: what this resource can do right now, plus context for the model.
  2. function_schema(capability): the functions a capability offers, with their JSON schemas.
  3. 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#