Packages
Add an AI brain to your Phoenix app - Auto-expose Ecto schemas as MCP tools
Current section
Files
Jump to
Current section
Files
lib/ectomancer.ex
defmodule Ectomancer do
@moduledoc """
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
1. 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}}
end
end
# Expose Phoenix routes as MCP tools
expose_routes MyAppWeb.Router
# Generates: get_users, post_users, get_user, put_user, delete_user, etc.
end
2. Add to your router:
forward "/mcp", Ectomancer.Plug, server: MyApp.MCP
3. 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}
end
end
## Available Macros
- `tool/2` - Define custom MCP tools with params and authorization
- `expose/2` - Auto-generate CRUD tools from Ecto schemas
- `expose_routes/1` - Auto-generate tools from Phoenix router routes
- `expose_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
end
### Policy Module
authorize with: MyApp.Policies.UserPolicy
### Public Access
authorize :none
## Actor 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
end
## Actor 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
end
If 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
end
### Extraction 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 |
"""
@doc """
Returns the version of Ectomancer.
"""
def version do
Application.spec(:ectomancer, :vsn) |> to_string()
end
@doc false
defmacro __using__(opts) do
quote do
use Anubis.Server,
name: Keyword.get(unquote(opts), :name, "ectomancer-server"),
version: Keyword.get(unquote(opts), :version, "0.1.0"),
capabilities: [:tools, :resources]
Module.register_attribute(__MODULE__, :ectomancer_resources, accumulate: true)
@before_compile Ectomancer
import Ectomancer.Tool, only: [tool: 2, authorize: 1]
import Ectomancer.Expose, only: [expose: 1, expose: 2]
import Ectomancer.RouteIntrospection, only: [expose_routes: 1, expose_routes: 2]
import Ectomancer.ObanBridge, only: [expose_oban_jobs: 0, expose_oban_jobs: 1]
end
end
@doc false
defmacro __before_compile__(env) do
resources = Module.get_attribute(env.module, :ectomancer_resources)
if resources == [] do
quote do
:ok
end
else
# The resources attribute accumulates entries in reverse order (LIFO)
resources = Enum.reverse(resources)
quote do
defmodule Resource.Schemas do
use Anubis.Server.Component,
type: :resource,
uri: "ectomancer://schemas",
name: "schemas",
mime_type: "application/json"
@moduledoc "Available Schemas"
def description, do: "Lists all available schemas in this Ectomancer server"
def read(_params, frame) do
schemas_list = unquote(Macro.escape(resources))
{:reply,
%Anubis.Server.Response{
type: :resource,
content: [
%{"type" => "text", "text" => Jason.encode!(%{"schemas" => schemas_list})}
]
}, frame}
end
end
Anubis.Server.component(Resource.Schemas)
end
end
end
end