Current section
Files
Jump to
Current section
Files
ectomancer
README.md
README.md
# Ectomancer
[](https://github.com/GustavoZiaugra/ectomancer/actions?query=workflow%3ACI)
[](https://hex.pm/packages/ectomancer)
[](https://hexdocs.pm/ectomancer)
[](https://hex.pm/packages/ectomancer)
[](https://github.com/GustavoZiaugra/ectomancer/blob/main/LICENSE)
**Auto-generate MCP tools from your Ecto schemas — your Phoenix app, now conversationally operable by Claude and any LLM.**
Ectomancer sits on top of [anubis_mcp](https://hex.pm/packages/anubis_mcp) and turns your database schemas into live MCP tools. Add it to your router, start the server, and your AI assistant can query, create, and update records through natural language — no hand-written tool definitions, no boilerplate.
## Features
- **Schema → MCP tools** — auto-generates CRUD tools from `expose MyApp.Accounts.User`
- **Route introspection** — `expose_routes MyAppWeb.Router` turns HTTP endpoints into callable tools
- **Authorization system** — inline functions, policy modules, or action-specific rules
- **Actor threading** — the current user flows through every tool call automatically
- **Custom tools** — `tool :search_users do ... end` with typed params
- **Upsert operations** — `upsert_{resource}` for insert-or-update workflows with conflict target and `on_conflict` control
- **Batch operations** — `batch_create`, `batch_update`, `batch_destroy` for transactional multi-record operations
- **Custom resources** — `resource :system_status do ... end` with URI templates, MIME types, and authorization
- **MCP Prompts** — `prompt :analyze_churn do ... end` for structured, parameterized prompt templates with argument validation
- **Rate limiting** — configurable token bucket per tool or globally
- **Multi-repo support** — expose schemas from different repos simultaneously
- **MCP Resources** — schemas auto-register at `ectomancer://schemas/{name}`, plus custom resources with URI templates, MIME types, and read handlers
- **Browser playground** — zero-dep HTML client at `priv/ectomancer.html`, no build step
- **Oban integration** — optional bridge for inspecting queue depth and workers
- **Interactive installer** — `mix ectomancer.setup` auto-discovers schemas and patches your project
- **Igniter installer** — `mix igniter.install ectomancer` auto-configures Ectomancer with schema selection, config files, router routes, and supervision tree
## Demo
Watch Ectomancer in action — a full Phoenix app with User, Post, and Comment schemas exposed as MCP tools, running on the Streamable HTTP transport.

*3 Ecto schemas → 15 MCP tools with zero boilerplate. Replay locally with `asciinema play priv/demo.cast`.*
The demo shows:
- **3 Ecto schemas** → **15+ MCP tools** (list, get, create, update, destroy + batch operations) with zero boilerplate
- **Advanced filtering** — `list_posts(title_contains: "MCP")` with automatic LIKE operators
- **Association support** — create posts linked to users through MCP tool calls
- **Real-time interaction** — query, create, and update records conversationally
Try it yourself:
```bash
git clone https://github.com/GustavoZiaugra/ectomancer_demo
cd ectomancer_demo
mix setup
mix phx.server
# Connect your MCP client to http://localhost:4000/mcp
```
## Installation
Add `ectomancer` to your dependencies:
```elixir
def deps do
[
{:ectomancer, "~> 1.7"}
]
end
```
Then run one of the installers:
### Option A: Igniter installer (recommended)
```bash
mix igniter.install ectomancer
```
This automatically:
1. Adds the `ectomancer` dependency
2. Checks for required dependencies (Ecto, Plug)
3. Discovers Ecto schemas in your project and prompts you to select which to expose
4. Generates an MCP module (`lib/my_app/mcp.ex`) with the selected schemas
5. Configures Ectomancer in `config/config.exs`
6. Adds the MCP route to your Phoenix router
7. Adds the Anubis supervisor to your application supervision tree
### Option B: Interactive setup
```bash
mix ectomancer.setup
```
An interactive wizard that does the same as above but runs as a standalone Mix task.
### Option C: Manual setup
```elixir
defmodule MyApp.MCP do
use Ectomancer,
name: "myapp-mcp",
version: "0.1.0",
authorize: MyApp.Policies.GlobalPolicy
expose MyApp.Accounts.User,
actions: [:list, :get, :create, :update]
expose MyApp.Blog.Post,
actions: [:list, :get]
tool :search_users do
description "Search users by email"
param :query, :string, required: true
param :limit, :integer
handle fn %{"query" => q, "limit" => l}, _actor ->
{:ok, MyApp.Accounts.search_users(q, limit: l || 10)}
end
end
resource :system_status do
description "Current system health metrics"
uri "metrics://status"
mime_type "application/json"
read fn _params, _actor ->
{:ok, Jason.encode!(%{status: "healthy", uptime: System.uptime()})}
end
end
prompt :analyze_churn do
description "Analyze user churn over a time period"
argument :days, :integer, required: true, description: "Days to look back"
argument :threshold, :float, default: 0.05, description: "Churn threshold"
messages fn args ->
[
%{
role: :user,
content: %{
type: :text,
text: "Using the list_users and get_user tools, analyze churn over the last #{args["days"]} days with threshold #{args["threshold"]}."
}
}
]
end
end
end
```
Then start the MCP server by adding to your Application supervisor:
```elixir
children = [
# ... other children ...
{MyApp.MCP, transport: {:streamable_http, start: true}},
MyAppWeb.Endpoint
]
```
And mount in your router:
```elixir
scope "/mcp" do
pipe_through :api
forward "/", Ectomancer.Plug, server: MyApp.MCP
end
```
Finally, configure actor extraction:
```elixir
config :ectomancer,
repo: MyApp.Repo,
actor_from: fn conn ->
conn.assigns.current_user
end
```
Done. Claude can now query your database through natural language at `/mcp`.
## Transports
Ectomancer supports three transport options. Streamable HTTP is the default and recommended transport.
### Transport Comparison
| Feature | Streamable HTTP | SSE (legacy) | WebSocket |
|---------|----------------|-------------|-----------|
| MCP protocol | 2025-03-26+ | 2024-11-05 | Any version |
| Status | **Recommended** | Deprecated | Available |
| Endpoints | Single (`forward`) | Dual (GET + POST) | Phoenix socket |
| Server notifications | Yes (SSE streaming) | Yes | Stub (future) |
| Router method | `forward` | `get` + `post` | `socket` in endpoint |
| Actor extraction | Plug.Conn | Plug.Conn | map (see below) |
### Streamable HTTP (default)
```elixir
# Supervision
{MyApp.MCP, transport: {:streamable_http, start: true}}
# Router
forward "/mcp", Ectomancer.Plug, server: MyApp.MCP
```
### SSE (legacy, deprecated)
For clients that only support the MCP 2024-11-05 HTTP+SSE protocol:
```elixir
# Supervision
{MyApp.MCP, transport: {:sse, start: true}}
# Router
get "/mcp/sse", Ectomancer.Plug, server: MyApp.MCP, transport: :sse
post "/mcp/sse", Ectomancer.Plug, server: MyApp.MCP, transport: :sse
```
### WebSocket
For bidirectional communication via WebSocket. Requires Phoenix's `socket` macro in your endpoint:
```elixir
# In lib/my_app/endpoint.ex
socket "/mcp/ws", Ectomancer.Plug.WebSocket,
server: MyApp.MCP,
websocket: [connect_info: [:x_headers, :uri, :peer_data]]
```
The server module is resolved from application config (not from socket-level options,
which Phoenix does not pass to transport callbacks):
```elixir
# In config/config.exs
config :ectomancer, :ws_server, MyApp.MCP
```
WebSocket actor extraction receives a map instead of a `Plug.Conn`:
```elixir
config :ectomancer,
actor_from: fn
%Plug.Conn{} = conn ->
# HTTP: standard Plug.Conn extraction
Ectomancer.Plug.extract_bearer_token(conn) |> MyApp.Auth.verify_token()
info when is_map(info) ->
# WebSocket: extract from query params or x_headers
case info.params["token"] do
nil ->
headers = info.connect_info[:x_headers] || []
{_, header_token} = List.keyfind(headers, "authorization", 0, {nil, nil})
String.replace_prefix(header_token || "", "Bearer ", "")
|> MyApp.Auth.verify_token()
token ->
MyApp.Auth.verify_token(token)
end
end
```
### Multiple Transports
One transport is supported per server module. `anubis_mcp` registers process
names derived from the server module, so starting two transports for the same
server collides. Use `Ectomancer.child_spec/2` (or a bare `{MyApp.MCP,
transport: {:streamable_http, start: true}}` entry) for a single transport:
```elixir
children = [
Ectomancer.child_spec(MyApp.MCP, transports: [:streamable_http]),
MyAppWeb.Endpoint
]
```
To serve multiple transports, define a dedicated server module per transport
and mount each in the router as shown above.
## Authorization
Three strategies, choose what fits:
| Style | Example | Use case |
|-------|---------|----------|
| **Inline** | `authorize fn actor, _ -> actor.role == :admin end` | Quick rules |
| **Policy module** | `authorize with: MyApp.Policies.UserPolicy` | Complex logic, reusable |
| **None** | `authorize :none` | Public endpoints |
### Global authorization
Set a policy for the entire server — it cascades to all schemas, custom tools, and route introspection tools:
```elixir
use Ectomancer,
name: "myapp-mcp",
authorize: fn actor, _ -> actor.role == :admin end
```
You can also use a policy module:
```elixir
use Ectomancer,
name: "myapp-mcp",
authorize: MyApp.Policies.GlobalPolicy
```
Per-schema `authorize` overrides the global policy for that schema. Action-specific rules override further. Both must pass when both are set (cascading).
### Per-schema and per-action rules
Schema-level and action-specific rules work too:
```elixir
expose MyApp.Accounts.User,
actions: [:list, :get, :create, :update],
authorize: [
list: :none,
get: fn actor, _ -> actor != nil end,
create: :admin_only,
update: MyApp.Policies.UserPolicy
]
```
Oban tools support the same per-action patterns:
```elixir
expose_oban_jobs authorize: [
all: fn actor, _ -> actor.role == :admin end,
list_queues: :none
]
```
## Configuration
### Sources
```elixir
config :ectomancer, repo: MyApp.Repo
```
### Query limits
`list` results are capped to `100` rows per call by default. Raise (or lower)
the ceiling globally:
```elixir
config :ectomancer, max_limit: 500
```
The effective limit is always reported in the `pagination` metadata of a
paginated `list` response, so clients can see when a request was clamped.
### Actor extraction
```elixir
config :ectomancer,
actor_from: fn conn ->
case Plug.Conn.get_req_header(conn, "authorization") do
["Bearer " <> token] -> MyApp.Auth.verify_token(token)
_ -> {:error, :unauthorized}
end
end
```
### Rate limiting
```elixir
config :ectomancer, :rate_limits,
enabled: true,
global: [max_requests: 100, time_window_ms: 60_000],
per_tool: [search_users: [max_requests: 10, time_window_ms: 60_000]]
```
### Telemetry
Ectomancer emits `:telemetry` events for monitoring, observability, and debugging.
Events are enabled by default. Disable by setting `telemetry: false`:
```elixir
config :ectomancer, telemetry: false
```
**Events emitted:**
| Event | When | Measurements | Metadata |
|-------|------|-------------|----------|
| `[:ectomancer, :tool, :start]` | Tool execution begins | `system_time` | `:tool` |
| `[:ectomancer, :tool, :stop]` | Tool execution ends | `duration` | `:tool` |
| `[:ectomancer, :tool, :exception]` | Tool handler raises | `duration` | `:tool` |
| `[:ectomancer, :repo, :start]` | CRUD operation begins | `system_time` | `:action`, `:schema` |
| `[:ectomancer, :repo, :stop]` | CRUD operation ends | `duration` | `:action`, `:schema` |
| `[:ectomancer, :repo, :exception]` | Repo operation raises | `duration` | `:action`, `:schema` |
| `[:ectomancer, :authorization, :denied]` | Auth check fails | (none) | `:actor`, `:action`, `:handler` |
| `[:ectomancer, :rate_limit, :exceeded]` | Rate limit exceeded | (none) | `:key`, `:window_ms` |
**Example: Attaching a handler**
```elixir
:telemetry.attach("ectomancer-logger", [:ectomancer, :tool, :stop], fn _name, measurements, metadata, _config ->
IO.puts("Tool #{metadata.tool} completed in #{System.convert_time_unit(measurements.duration, :native, :millisecond)}ms")
end, nil)
```
### Multi-repo
```elixir
expose MyApp.OtherSchema, repo: MyApp.ReplicaRepo
```
### Row-level scoping (multi-tenant)
Use the `scope:` option to restrict every generated query to the calling actor's
tenant. The function receives the Ecto query and the authenticated actor and
returns a scoped query:
```elixir
expose MyApp.Accounts.Workspace,
actions: [:list, :get, :create, :update, :destroy],
scope: fn query, actor ->
import Ecto.Query
from(w in query, where: w.distribution_id == ^actor.distribution_id)
end
```
The scope is applied to `list`, `get`, `update`, `destroy`, and batch operations.
It composes with authorization-policy scopes (`{:ok, :scoped, fn query -> ... end}`)
when both are configured.
### Field filtering on results
`only:` and `except:` control which fields are exposed. In addition to shaping
input params, they now also **redact read results** — excluded fields are stripped
from every row returned by `list`, `get`, and batch tools:
```elixir
# password_hash will never appear in tool output, only in params it is omitted
expose MyApp.Accounts.User, except: [:password_hash, :secret_token]
```
## Batch operations
Perform multi-record mutations in a single transactional call:
```elixir
expose MyApp.Accounts.User,
actions: [:list, :get, :batch_create, :batch_update, :batch_destroy],
batch_size: 200
```
| Action | Tool Name | Input | Behavior |
|--------|-----------|-------|----------|
| `batch_create` | `batch_create_users` | `records: [%{...}]` | Validates and inserts each record in a single transaction |
| `batch_update` | `batch_update_users` | `records: [%{id, ...}]` | Fetches and updates each record in a single transaction |
| `batch_destroy` | `batch_destroy_users` | `ids: [...]` | Fetches and deletes each record in a single transaction |
Each record is processed inside the shared transaction, and a database-level
failure on one record is contained by a savepoint so it cannot abort the rest of
the batch. **The batch is best-effort, not atomic** — records that succeed are
committed even when others fail:
```elixir
# Result shape: %{succeeded: [%{status: :ok, record: ...}], failed: [...], total: 3}
```
Partial failures are reported alongside successes so the AI assistant can retry
or report the failed records.
Batch operations respect authorization, scope, soft-delete, and field auth just like single-record operations.
## Upsert
Insert a new record or update an existing one in a single call based on a conflict target:
```elixir
expose MyApp.Products.Product,
actions: [:upsert],
conflict_target: :sku,
on_conflict: :replace_all
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `conflict_target` | `atom \| [atom]` | (required) | Field(s) to check for existing records |
| `on_conflict` | `:replace_all \| [set: [...]]` | `:replace_all` | Which fields to update when a conflict is found |
Generated tool `upsert_product` accepts all writable fields. If a record matching the `conflict_target` exists, it's updated; otherwise, a new record is inserted.
**Return metadata** — the response indicates whether the record was inserted or updated:
```
{%MyApp.Products.Product{...}, :inserted}
{%MyApp.Products.Product{...}, :updated}
```
**Composite keys** — use a list for multi-field matching:
```elixir
expose MyApp.Inventory.Item,
actions: [:upsert],
conflict_target: [:org_id, :sku]
```
**Selective updates** — control which fields change on conflict:
```elixir
expose MyApp.Accounts.User,
actions: [:upsert],
conflict_target: :email,
on_conflict: [set: [:name, :avatar_url]]
```
Upsert is **soft-delete aware** — upserting onto a soft-deleted record restores it (sets `deleted_at` to `nil`).
## Prompts
Define structured, parameterized prompt templates for LLM clients. Prompts are reusable blueprints that generate messages based on runtime arguments — the AI assistant can request them to kick off common workflows.
```elixir
prompt :summarize_reports do
description "Summarize recent reports by type"
argument :report_type, :string,
required: true,
description: "Type of report",
enum: ["sales", "inventory", "employee"]
messages fn args ->
report_type = Map.get(args, "report_type", "sales")
[
%{
role: :system,
content: %{
type: :text,
text: "You are a report analyst. Summarize the #{report_type} reports."
}
},
%{
role: :user,
content: %{
type: :text,
text: "Provide a concise summary of the latest #{report_type} reports."
}
}
]
end
end
```
Prompts integrate with the MCP `prompts/list` and `prompts/get` protocol methods via `Anubis.Server.component/2`. Arguments support `required`, `default`, `description`, and `enum` constraints.
## Pages
| Path | Description |
|------|-------------|
| `/mcp` | MCP endpoint (Streamable HTTP) |
| `/mcp/sse` | SSE endpoint (legacy, transport: :sse) |
| `/mcp/ws` | WebSocket endpoint (via Phoenix socket) |
Open `priv/ectomancer.html` in a browser for a visual playground — browse tools, fill params, call them, and inspect results. No build step, no npm install, no dependencies.
## Documentation
- [HexDocs](https://hexdocs.pm/ectomancer)
- [GitHub Pages](https://gustavoZiaugra.github.io/ectomancer)
- [GitHub](https://github.com/GustavoZiaugra/ectomancer)
## Testing
```bash
mix test
```
Zero compiler warnings, full Credo and Dialyzer compliance.
Current version: **1.7.0**
## License
MIT