MCP Server in Phoenix

Expose your app’s actions to Claude Code, Cursor, and other AI coding agents over the Model Context Protocol. Your Jido actions become tools an AI can call from inside the user’s editor: list_users, get_site_stats, whatever you define.

This is the pattern behind Petal Pro’s admin MCP server. The same Jido.Action modules power both the in-app admin chat and the external MCP endpoint. Add a tool once, get it in two places.

MCP is JSON-RPC 2.0 over HTTP. Phoenix handles it natively, with no special framework needed.

defmodule MyAppWeb.McpController do
@moduledoc "JSON-RPC 2.0 endpoint for MCP clients (Claude, Cursor, etc)."
use MyAppWeb, :controller
alias MyApp.AI.ToolRegistry
plug :authenticate
def handle(conn, %{"jsonrpc" => "2.0", "id" => id, "method" => method} = params) do
args = Map.get(params, "params", %{})
result = dispatch(method, args)
json(conn, Map.merge(%{"jsonrpc" => "2.0", "id" => id}, result))
end
defp dispatch("initialize", _args) do
%{
"result" => %{
"protocolVersion" => "2024-11-05",
"capabilities" => %{"tools" => %{}},
"serverInfo" => %{"name" => "MyApp MCP", "version" => "1.0.0"}
}
}
end
defp dispatch("tools/list", _args) do
tools =
Enum.map(ToolRegistry.actions(), fn action ->
meta = action.__action_metadata__()
%{
"name" => meta.name,
"description" => meta.description,
"inputSchema" => schema_to_json(meta.schema)
}
end)
%{"result" => %{"tools" => tools}}
end
defp dispatch("tools/call", %{"name" => name, "arguments" => args}) do
case ToolRegistry.execute(name, args) do
{:ok, result} ->
%{"result" => %{"content" => [%{"type" => "text", "text" => inspect(result)}]}}
{:error, reason} ->
%{"error" => %{"code" => -32603, "message" => inspect(reason)}}
end
end
defp authenticate(conn, _opts) do
expected = "Bearer " <> System.get_env("MCP_ADMIN_TOKEN", "")
case Plug.Conn.get_req_header(conn, "authorization") do
[^expected] -> conn
_ -> conn |> send_resp(401, "Unauthorized") |> halt()
end
end
defp schema_to_json(schema) do
properties =
Map.new(schema, fn {key, opts} ->
{to_string(key), %{"type" => json_type(opts[:type]), "description" => opts[:doc]}}
end)
required =
schema
|> Enum.filter(fn {_, opts} -> opts[:required] end)
|> Enum.map(fn {k, _} -> to_string(k) end)
%{"type" => "object", "properties" => properties, "required" => required}
end
defp json_type(:string), do: "string"
defp json_type(:integer), do: "integer"
defp json_type(:boolean), do: "boolean"
defp json_type({:in, _}), do: "string"
defp json_type(_), do: "string"
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/api", MyAppWeb do
pipe_through :api
post "/mcp", McpController, :handle
end
# Local dev
claude mcp add my-app \
--transport http \
--header "Authorization: Bearer dev-mcp-token" \
http://localhost:4000/api/mcp
# Production
claude mcp add my-app \
--transport http \
--header "Authorization: Bearer $MCP_ADMIN_TOKEN" \
https://my-app.fly.dev/api/mcp
{
"mcpServers": {
"my-app": {
"type": "http",
"url": "http://localhost:4000/api/mcp",
"headers": {
"Authorization": "Bearer dev-mcp-token"
}
}
}
}

What you can actually do with this

  • Ask Claude Code from your editor: "how many users signed up this week?" and it calls your get_site_stats tool and answers from real data.
  • Use it as a customer support copilot: "summarise this user's account" pulls their full profile, memberships, and recent activity.
  • Pair with the tool calling recipe. The same Jido actions work for in-app admin chat and the external MCP endpoint. One registry, two surfaces.
  • Reference impl: mcp.petal.build exposes the petal_components schemas to AI coding tools via this exact pattern.