Ectomancer

CIHex.pmHex DocsDownloadsLicense

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

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.

Ectomancer Demo

3 Ecto schemas → 15 MCP tools with zero boilerplate. Replay locally with asciinema play priv/demo.cast.

The demo shows:

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, "~> 1.7"}
]
end

Then run one of the installers:

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

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 three transport options. Streamable HTTP is the default and recommended transport.

Transport Comparison

FeatureStreamable HTTPSSE (legacy)WebSocket
MCP protocol2025-03-26+2024-11-05Any version
StatusRecommendedDeprecatedAvailable
EndpointsSingle (forward)Dual (GET + POST)Phoenix socket
Server notificationsYes (SSE streaming)YesStub (future)
Router methodforwardget + postsocket in endpoint
Actor extractionPlug.ConnPlug.Connmap (see below)

Streamable HTTP (default)

# 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:

# 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:

# 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

Three strategies, choose what fits:

StyleExampleUse case
Inlineauthorize fn actor, _ -> actor.role == :admin endQuick rules
Policy moduleauthorize with: MyApp.Policies.UserPolicyComplex logic, reusable
Noneauthorize :nonePublic 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: [
all: fn actor, _ -> actor.role == :admin end,
list_queues: :none
]

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:

EventWhenMeasurementsMetadata
[:ectomancer, :tool, :start]Tool execution beginssystem_time:tool
[:ectomancer, :tool, :stop]Tool execution endsduration:tool
[:ectomancer, :tool, :exception]Tool handler raisesduration:tool
[:ectomancer, :repo, :start]CRUD operation beginssystem_time:action, :schema
[:ectomancer, :repo, :stop]CRUD operation endsduration:action, :schema
[:ectomancer, :repo, :exception]Repo operation raisesduration: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],
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:

# 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:

expose MyApp.Accounts.User,
actions: [:list, :get, :batch_create, :batch_update, :batch_destroy],
batch_size: 200
ActionTool NameInputBehavior
batch_createbatch_create_usersrecords: [%{...}]Validates and inserts each record in a single transaction
batch_updatebatch_update_usersrecords: [%{id, ...}]Fetches and updates each record in a single transaction
batch_destroybatch_destroy_usersids: [...]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],
conflict_target: :sku,
on_conflict: :replace_all
OptionTypeDefaultDescription
conflict_targetatom | [atom](required)Field(s) to check for existing records
on_conflict:replace_all | [set: [...]]:replace_allWhich 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],
conflict_target: [:org_id, :sku]

Selective updates — control which fields change on conflict:

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.

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

PathDescription
/mcpMCP endpoint (Streamable HTTP)
/mcp/sseSSE endpoint (legacy, transport: :sse)
/mcp/wsWebSocket 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

Zero compiler warnings, full Credo and Dialyzer compliance.

Current version: 1.7.0

License

MIT