Ectomancer - Add an AI brain to your Phoenix app.
Automatically exposes your Ecto schemas as MCP (Model Context Protocol) tools, making your Phoenix app conversationally operable by Claude and other LLMs.
Quick Start
Create your MCP module:
defmodule MyApp.MCP do use Ectomancer,
name: "my-app-mcp", version: "1.0.0"# Expose Ecto schemas as MCP tools expose MyApp.Accounts.User,
actions: [:list, :get, :create, :update]# Custom tools with authorization tool :admin_stats do
description "Get admin statistics" authorize fn actor, _action -> actor != nil && actor.role == :admin end handle fn _params, _actor -> {:ok, %{users: 100, revenue: 5000}} endend
# Expose Phoenix routes as MCP tools expose_routes MyAppWeb.Router # Generates: get_users, post_users, get_user, put_user, delete_user, etc. end
Add to your router:
forward "/mcp", Ectomancer.Plug, server: MyApp.MCP
Configure actor extraction:
config :ectomancer, actor_from: fn conn ->
conn |> Plug.Conn.get_req_header("authorization") |> List.first() |> case do nil -> {:error, :unauthorized} "Bearer " <> token -> MyApp.Auth.verify_token(token) _ -> {:error, :unauthorized} endend
Available Macros
tool/2- Define custom MCP tools with params and authorizationexpose/2- Auto-generate CRUD tools from Ecto schemasexpose_routes/1- Auto-generate tools from Phoenix router routesexpose_oban_jobs/0- Auto-generate Oban job management tools (requires Oban)authorize/1- Add authorization to tools (use inside tool block)
Authorization
Ectomancer provides flexible authorization:
Inline Function
authorize fn actor, action ->
actor != nil && actor.role == :admin
endPolicy Module
authorize with: MyApp.Policies.UserPolicyPublic Access
authorize :noneActor Access in Tools
Tools receive the actor via frame.assigns:
defmodule MyApp.MCP.MyTool do
def execute(params, frame) do
actor = frame.assigns[:ectomancer_actor]
# Use actor for authorization...
end
endActor Extraction
Ectomancer extracts the actor (the user/entity making the request) from the Plug connection and threads it through the system automatically. Here's how it flows:
1. Configuration
Define an actor_from function in your config. This function receives the
Plug.Conn struct and returns the actor (or {:error, reason} to reject):
config :ectomancer,
actor_from: fn conn ->
case Plug.Conn.get_req_header(conn, "authorization") do
["Bearer " <> token] ->
case MyApp.Auth.verify_token(token) do
{:ok, user} -> user
{:error, _} -> {:error, :unauthorized}
end
_ ->
{:error, :unauthorized}
end
endIf no actor_from is configured, the actor defaults to nil (unauthenticated).
2. Extraction (Plug layer)
Ectomancer.Plug.extract_actor/1 calls your actor_from function on every
incoming request. If the function returns {:error, reason}, the request is
rejected with HTTP 401 before reaching any MCP tools.
3. Threading (MCP frame)
The extracted actor is placed into conn.assigns[:ectomancer_actor]. Anubis MCP
propagates this into frame.assigns[:ectomancer_actor] for every tool call
within that session — you never need to re-extract it.
4. Authorization
Tool handlers and expose-generated CRUD tools receive the actor automatically:
# Custom tool
tool :my_tool do
authorize fn actor, action -> actor != nil end
handle fn params, actor, scope ->
# actor is the user/entity from actor_from
{:ok, %{message: "Hello, #{actor.name}"}}
end
end
# Exposed schema (authorization configured separately)
expose MyApp.User, authorize: fn actor, action ->
actor.role == :admin
endExtraction Flow Summary
| Layer | Location | What happens |
|---|---|---|
| Config | config :ectomancer, actor_from: ... | User provides extraction function |
| Plug | Ectomancer.Plug.call/2 | Calls extract_actor(conn) |
| Assigns | conn.assigns[:ectomancer_actor] | Actor stored in connection |
| Frame | frame.assigns[:ectomancer_actor] | Anubis propagates to tool context |
| Tool | execute(params, frame) | Actor accessible via frame.assigns |
| Handler | handle(params, actor, scope) | Actor passed as 2nd argument |
| Authorization | Authorization.check/3 | Actor + action checked against policy |