ectomancer
Health Uyari
- License — License: MIT
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Low visibility — Only 8 GitHub stars
Code Gecti
- Code scan — Scanned 5 files during light audit, no dangerous patterns found
Permissions Gecti
- Permissions — No dangerous permissions requested
Bu listing icin henuz AI raporu yok.
Turn Ecto schemas into a safe MCP server for Claude.
Ectomancer
Turn your Ecto schemas into a safe MCP server for Claude.
Ectomancer exposes selected Ecto schemas as MCP tools, so Claude, Cursor, or any MCP client can query and update your Phoenix app's data using your existing authorization — not a database password.

3 Ecto schemas → 15+ MCP tools. No hand-written tool definitions.
mix igniter.install ectomancer
Try the demo · Read the docs · Watch the demo
Why Ectomancer
- Safe by default —
exposeis read-only (list/get); mutations require an explicitauthorize:. - Your auth, not DB credentials — actor threading and scopes flow through every tool call.
- Ecto-native — uses your existing schemas and associations. No SQL strings or duplicate models.
- One command — the Igniter installer adds the dependency, discovers schemas, patches your router, and wires the supervision tree.
- Any MCP client — Claude, Cursor, Codex, or your own client over Streamable HTTP or WebSocket.
Why not just use a Postgres MCP?
| Generic Postgres MCP | Hand-written MCP tools | Ectomancer | |
|---|---|---|---|
| Access | DB credentials | Custom code | App-level, scoped |
| Auth | DB roles | You build it | Your existing actor/auth |
| Schema semantics | Raw SQL | Manual | Ecto schemas |
| Setup | Connection string | Days | One command |
| Blast radius | Whole database | Whatever you built | Only exposed schemas/actions |
Don't give Claude your database password. Give it scoped tools that respect your app's authorization.
Quick start
defmodule MyApp.MCP do
use Ectomancer, name: "myapp-mcp"
expose MyApp.Accounts.User, actions: [:list, :get]
end
Add it to your supervision tree and router:
# application.ex
children = [
Ectomancer.child_spec(MyApp.MCP, transports: [:streamable_http]),
MyAppWeb.Endpoint
]
# router.ex
forward "/mcp", Ectomancer.Plug, server: MyApp.MCP
See Installation for the one-command installer and full setup.
Who it's for
- Phoenix apps with Ecto/Postgres and an internal admin, support, or analytics use case.
- Teams using Claude, Cursor, Codex, or another MCP client.
- Developers who want scoped app access, not a raw SQL console.
Not for: non-Elixir apps, teams that need a generic database MCP, or anyone looking for an unauthenticated public data API.
Using Ectomancer? Add yourself to ADOPTERS.md — it helps other Phoenix developers see what is possible.
Full feature list- 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
The demo is a full Phoenix app with User, Post, and Comment schemas exposed as MCP tools over 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 bareexpose 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/:public
opt-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 emitsWWW-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 witherrors: "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
exampleRepo.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 andmode: :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.3
License
MIT
Yorumlar (0)
Yorum birakmak icin giris yap.
Yorum birakSonuc bulunamadi