ectomancer

mcp
Security Audit
Warn
Health Warn
  • License — License: MIT
  • Description — Repository has a description
  • Active repo — Last push 0 days ago
  • Low visibility — Only 8 GitHub stars
Code Pass
  • Code scan — Scanned 5 files during light audit, no dangerous patterns found
Permissions Pass
  • Permissions — No dangerous permissions requested

No AI report is available for this listing yet.

SUMMARY

Turn Ecto schemas into a safe MCP server for Claude.

README.md

Ectomancer

CI
Hex.pm
Hex Docs
Downloads
License

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.

Ectomancer Demo

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 — expose is read-only (list/get); mutations require an explicit authorize:.
  • 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.Router turns HTTP endpoints into callable tools
  • Authorization system — inline functions, policy modules, or action-specific rules
  • Fail-closed by default — expose only enables read-only list/get; mutating actions require an :authorize option or an explicit authorize: :none opt-in, enforced at compile time (#139). The same guard covers mutating expose_routes routes and expose_oban_jobs retry_job/cancel_job tools
  • Write-time scope enforcement — scope: now covers writes too: created/upserted rows (including nested create with associations:) 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-resource route helper and RFC 9728 WWW-Authenticate challenges for claude.ai custom connectors (#174)
  • 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

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:

  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) exposing the selected schemas read-only (list/get tools); add an :authorize option to the generated expose calls to enable mutating actions
  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 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_mcp 2.0 dropped the deprecated HTTP+SSE transport and the MCP
2024-11-05 protocol version, so transport: :sse now 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 SomeSchema generates list_*/get_* tools
with no authorization and no scope, so any caller (including anonymous
ones, when actor_from is unset) can read every row — across tenants, if any.
Always add authorize: and/or scope: to every expose that touches
multi-tenant or sensitive data. The installer scaffolds a reminder comment
into the generated MCP module.

Fail-closed by default. expose only generates read-only tools (list/get).
Any mutating action (create, update, destroy, restore, upsert, and the
batch variants) requires effective authorization: pass an :authorize option
(function, policy module, or per-action rules) at the server or schema level, or
explicitly opt in to public mutation with authorize: :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_routes routes (POST/PUT/PATCH/
DELETE) and the mutating expose_oban_jobs tools (retry_job, cancel_job):
each requires effective authorization or an explicit authorize: :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/:public rule 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 returned
  • create/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 with associations: — the parent is
    verified after its children are inserted, and parent and children roll back
    together on a violation
  • update/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 entry
  • destroy/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/1 wrapping several tool calls), a scope violation
    aborts that transaction: the offending call returns Resource not found, and
    the caller's Repo.transaction returns {: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: and authorize: 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.3

License

MIT

Reviews (0)

No results found