Setting up MCP#
The application template 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
transport. 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, 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: <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 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.