Ectomancer
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 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.Routerturns HTTP endpoints into callable tools - Authorization system — inline functions, policy modules, or action-specific rules
- Fail-closed by default —
exposeonly enables read-onlylist/get; mutating actions require an:authorizeoption or an explicitauthorize: :noneopt-in, enforced at compile time (#139). The same guard covers mutatingexpose_routesroutes andexpose_oban_jobsretry_job/cancel_jobtools - Write-time scope enforcement —
scope:now covers writes too: created/upserted rows (including nestedcreatewithassociations:) are re-selected through the scope and rolled back when not visible, and updated rows are re-checked after the write (#174) - OAuth discovery —
/.well-known/oauth-protected-resourceroute helper and RFC 9728WWW-Authenticatechallenges for claude.ai custom connectors (#174) - Actor threading — the current user flows through every tool call automatically
- Custom tools —
tool :search_users do ... endwith typed params - Upsert operations —
upsert_{resource}for insert-or-update workflows with conflict target andon_conflictcontrol - Batch operations —
batch_create,batch_update,batch_destroyfor transactional multi-record operations - Custom resources —
resource :system_status do ... endwith URI templates, MIME types, and authorization - MCP Prompts —
prompt :analyze_churn do ... endfor 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.setupauto-discovers schemas and patches your project - Igniter installer —
mix igniter.install ectomancerauto-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:
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:
def deps do
[
{:ectomancer, "~> 3.0"}
]
end
Then run one of the installers:
Option A: Igniter installer (recommended)
mix igniter.install ectomancer
This automatically:
- Adds the
ectomancerdependency - Checks for required dependencies (Ecto, Plug)
- Discovers Ecto schemas in your project and prompts you to select which to expose
- Generates an MCP module (
lib/my_app/mcp.ex) exposing the selected schemas read-only (list/gettools); add an:authorizeoption to the generatedexposecalls to enable mutating actions - Configures Ectomancer in
config/config.exs - Adds the MCP route to your Phoenix router
- Adds the Anubis supervisor to your application supervision tree
Option B: Interactive setup
mix ectomancer.setup
An interactive wizard that does the same as above but runs as a standalone Mix task.
Option C: Manual setup
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:
children = [
# ... other children ...
{MyApp.MCP, transport: {:streamable_http, start: true}},
MyAppWeb.Endpoint
]
And mount in your router:
scope "/mcp" do
pipe_through :api
forward "/", Ectomancer.Plug, server: MyApp.MCP
end
Finally, configure actor extraction:
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 two transport options. Streamable HTTP is the default and recommended transport.
Upgrading from 2.x? The legacy SSE transport was removed in 3.0.
anubis_mcp2.0 dropped the deprecated HTTP+SSE transport and the MCP 2024-11-05 protocol version, sotransport: :ssenow raises. Point your MCP clients at the Streamable HTTP endpoint below (MCP 2025-03-26+).
Transport Comparison
| Feature | Streamable HTTP | WebSocket |
|---|---|---|
| MCP protocol | 2025-03-26+ | Any version |
| Status | Recommended | Available |
| Endpoints | Single (forward) |
Phoenix socket |
| Server notifications | Yes (SSE streaming) | Stub (future) |
| Router method | forward |
socket in endpoint |
| Actor extraction | Plug.Conn | map (see below) |
Streamable HTTP (default)
# Supervision
{MyApp.MCP, transport: {:streamable_http, start: true}}
# Router
forward "/mcp", Ectomancer.Plug, server: MyApp.MCP
WebSocket
For bidirectional communication via WebSocket. Requires Phoenix's socket macro in your endpoint:
# 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):
# In config/config.exs
config :ectomancer, :ws_server, MyApp.MCP
WebSocket actor extraction receives a map instead of a Plug.Conn:
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:
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
Read actions are public by default. The fail-closed guard above covers mutations only: a bare
expose SomeSchemagenerateslist_*/get_*tools with no authorization and no scope, so any caller (including anonymous ones, whenactor_fromis unset) can read every row — across tenants, if any. Always addauthorize:and/orscope:to everyexposethat touches multi-tenant or sensitive data. The installer scaffolds a reminder comment into the generated MCP module.
Fail-closed by default.
exposeonly generates read-only tools (list/get). Any mutating action (create,update,destroy,restore,upsert, and the batch variants) requires effective authorization: pass an:authorizeoption (function, policy module, or per-action rules) at the server or schema level, or explicitly opt in to public mutation withauthorize: :none. If you expose a mutating action without either, compilation fails with a clear error — the old behavior silently produced an unauthenticated full-CRUD endpoint.The same guard applies to mutating
expose_routesroutes (POST/PUT/PATCH/DELETE) and the mutatingexpose_oban_jobstools (retry_job,cancel_job): each requires effective authorization or an explicitauthorize: :none/:publicopt-in, enforced at compile time. Read-only route and Oban tools are always allowed.
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:
use Ectomancer,
name: "myapp-mcp",
authorize: fn actor, _ -> actor.role == :admin end
You can also use a policy module:
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:
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:
expose_oban_jobs authorize: [
list_queues: :none,
retry_job: fn actor, _ -> actor.role == :admin end,
cancel_job: fn actor, _ -> actor.role == :admin end
]
Unlike
expose, an Oban per-action:none/:publicrule overrides a server-level policy rather than cascading to it. The macro always emits all five tools, so to keep reads public while locking down mutations, deny the mutating tools explicitly:expose_oban_jobs authorize: [ retry_job: fn _actor, _action -> false end, cancel_job: fn _actor, _action -> false end ]
Read-only tools (list_oban_queues, get_queue_depth, list_stuck_jobs) are
always allowed. retry_job/cancel_job are mutating and require authorization
— or an explicit opt-in:
expose_oban_jobs(authorize: :none) # public mutation — explicit
expose_routes guards mutating HTTP methods the same way — add :authorize,
or restrict with methods: ["GET"] for read-only exposure:
expose_routes MyAppWeb.Router, methods: ["GET"] # read-only only
expose_routes MyAppWeb.Router,
authorize: fn actor, _action -> actor.role == :admin end
OAuth discovery (claude.ai custom connectors)
MCP clients that support OAuth discovery (claude.ai custom connectors) need two
things: an RFC 9728 protected-resource document and a WWW-Authenticate
challenge on unauthorized responses.
Serve the discovery document with the bundled plug:
# In config/config.exs
config :ectomancer, :oauth_protected_resource, %{
"resource" => "https://your-app.com/mcp",
"authorization_servers" => ["https://auth.your-app.com"],
"scopes_supported" => ["mcp"]
}
# In your router
get "/.well-known/oauth-protected-resource",
Ectomancer.Plug.OAuthProtectedResource,
[]
To make 401 responses point clients at that document, configure the URL:
config :ectomancer, :oauth,
resource_metadata_url: "https://your-app.com/.well-known/oauth-protected-resource"
Ectomancer.Plug then emits
WWW-Authenticate: Bearer resource_metadata="https://your-app.com/.well-known/oauth-protected-resource"
on every 401. Without this config, 401 responses keep the plain JSON body (fine
for Claude Code with a static bearer token).
Configuration
Sources
config :ectomancer, repo: MyApp.Repo
Query limits
list results are capped to 100 rows per call by default. Raise (or lower)
the ceiling globally:
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
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
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:
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
: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
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:
expose MyApp.Accounts.Workspace,
actions: [:list, :get, :create, :update, :destroy],
authorize: :none, # or a real authorization handler; required for mutating actions
scope: fn query, actor ->
import Ecto.Query
from(w in query, where: w.distribution_id == ^actor.distribution_id)
end
The scope is applied to every generated action — reads and writes:
list/get— only rows matching the scope are returnedcreate/upsert(insert path)/batch_create— the inserted row is re-selected through the scope inside the same transaction and rolled back if it is not visible (an actor cannot write rows under another tenant by supplying a foreign tenant key). This includes nested creation withassociations:— the parent is verified after its children are inserted, and parent and children roll back together on a violationupdate/batch_update— the row is fetched through the scope and re-checked after the write; if the update moved the row out of scope, the transaction is rolled back (single update) or the original values are restored and the row is reported as a failed batch entrydestroy/restore/batch_destroy— the row is fetched through the scope before the operation
A scope violation is reported to clients as Resource not found (the same
non-leaky shape as a scoped-out read) or as a per-record failed entry with
errors: "scope_violation" in batch results.
Two fail-closed guarantees apply when the safety net itself cannot complete:
- If the post-write re-select or the compensating write (rollback/undo/restore) raises — DB trigger, constraint, connection loss — the whole batch is rolled back rather than committing a row that may sit outside the actor's scope.
- If a scoped write is executed inside a caller-managed transaction (for
example
Repo.transaction/1wrapping several tool calls), a scope violation aborts that transaction: the offending call returnsResource not found, and the caller'sRepo.transactionreturns{:error, :rollback}with all writes rolled back. - Scoped writes on schemas without a primary key cannot be verified (there is no key to re-select by), so they are rejected instead of silently allowed.
The scope composes with authorization-policy scopes ({:ok, :scoped, fn query -> ... end})
when both are configured.
DX note: functions passed to
scope:andauthorize:are spliced as AST into every generated tool module, so they cannot call local helpers of the host module. Write host helpers as fully qualified remote calls:
# Wrong — generated module fails to compile: undefined function tenant_ids/1
# scope: fn query, actor -> from(w in query, where: w.tenant_id in ^tenant_ids(actor)) end
# Right — remote call into the host module
expose MyApp.Accounts.Workspace,
scope: fn query, actor ->
import Ecto.Query
from(w in query, where: w.tenant_id in ^MyApp.MCP.tenant_ids(actor))
end
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:
# password_hash will never appear in tool output, only in params it is omitted
expose MyApp.Accounts.User, except: [:password_hash, :secret_token]
Ordering is restricted to fields the caller can actually see: order_by is
resolved against the visible field set, so a field hidden with except: cannot
control row ordering — otherwise a client could bisect its values through the
row order.
The same applies to field_authorize:, a dynamic callback that strips fields
per actor:
expose MyApp.Accounts.User,
field_authorize: fn actor, field ->
field != :salary or actor.role == :admin
end
Fields denied to the current actor are stripped from results and excluded
from the order_by allowlist.
Batch operations
Perform multi-record mutations in a single transactional call:
expose MyApp.Accounts.User,
actions: [:list, :get, :batch_create, :batch_update, :batch_destroy],
authorize: :none, # or a real authorization handler; required for mutating actions
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:
# 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:
expose MyApp.Products.Product,
actions: [:upsert],
authorize: :none, # or a real authorization handler; required for mutating actions
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:
expose MyApp.Inventory.Item,
actions: [:upsert],
authorize: :none, # or a real authorization handler; required for mutating actions
conflict_target: [:org_id, :sku]
Selective updates — control which fields change on conflict:
expose MyApp.Accounts.User,
actions: [:upsert],
authorize: :none, # or a real authorization handler; required for mutating actions
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.
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/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
Testing
mix test
The suite runs against in-memory SQLite. Scope-write rollback/undo semantics and
mode: :savepoint batch containment are also validated against a real Postgres
(because a failed statement aborts a Postgres transaction, unlike SQLite):
# any reachable Postgres database works
ECTOMANCER_PG_URL=postgres://postgres:postgres@localhost:5432/ectomancer_test \
mix test --only postgres
The postgres scope tests CI job runs the same command against a postgres:16
service container.
Zero compiler warnings, full Credo and Dialyzer compliance.
Current version: 3.0.2
License
MIT