Build an MCP Server in Phoenix With Hermes
An MCP server lets Claude and other agents call your Phoenix app directly. How to ship one with Hermes — auth, transport, and safe tool boundaries.
TL;DR: An MCP server turns your Phoenix app into a set of tools that Claude — or any MCP-aware agent — can call directly, instead of you copy-pasting database dumps into a chat window. The Elixir library for this is Hermes, and it has first-class Phoenix support: you define tools as components, mount one plug, and you’re live. The honest catch is that MCP is young and the spec has churned — the transport moved from HTTP+SSE to Streamable HTTP, and the auth story is still maturing. The whole discipline of building a good MCP server is restraint: you expose a small, typed, authorized allow-list of tools, never raw database access. If you only have one consumer and a stable internal API already, you may not need one yet. Read to the end for the decision guidance.
You want Claude to answer questions about your real app, not a stale CSV
Here’s the scenario that makes this concrete. You’re on a call and someone asks: “Which customers are over their plan limit right now?” You could answer that. You’d open a console, write a query, paste the rows somewhere, and reason about them. Or you’d ask Claude — except Claude can’t see your database. So you dump a CSV into the chat, it’s already stale by the time you hit enter, and you’ve just pasted customer data into a context window with no audit trail and no access control.
An MCP server fixes the shape of that problem. Instead of moving
data to the agent, you give the agent a tool it can
call: customers_over_limit. The agent decides when to call
it, your app runs the actual query under your actual authorization
rules, and only the result comes back. Claude becomes a client of your
application’s capabilities — the same way a mobile app or a cron job is
a client — except the “API” is described in a format the model already
knows how to consume.
That’s the entire pitch. Your app, as a set of tools an agent can call. The interesting engineering is not in wiring it up — Hermes makes that almost boring — it’s in deciding which tools to expose and how to guard them. We’ll build a real one, then spend the back half of this post on the boundary, because the boundary is the whole point.
What MCP actually is, in three nouns
The Model Context Protocol is a JSON-RPC-based protocol that standardizes how an AI application (the host, like Claude Desktop or an agent runtime) talks to external systems. Strip away the spec language and there are three things a server can offer:
- Tools — functions the model can call, with
typed inputs and structured outputs. This is the active verb:
lookup_customer,create_ticket,customers_over_limit. Tools are the part you’ll use 90% of the time. - Resources — data the model can read, addressed by URI. Think of these as GET endpoints: a file, a record, a document the host can pull into context.
- Prompts — reusable prompt templates your server hands to the host, so common workflows aren’t reinvented in every client.
Underneath those sits a transport. Originally MCP used stdio (for local servers spawned as subprocesses) and HTTP with Server-Sent Events for remote ones. As of the 2025-03-26 revision of the spec, the remote transport is Streamable HTTP: the client POSTs JSON-RPC messages to one endpoint, and the server can either reply with a plain JSON response or upgrade to an SSE stream for server-to-client messages. The older HTTP+SSE transport still exists in the wild, but Streamable HTTP is what you should target for anything new. (MCP moves fast — as of mid-2026, verify the current transport revision against the spec before you ship, because this is exactly the kind of detail that shifts between releases.)
For a Phoenix app, Streamable HTTP is the natural fit: it’s just another route. You don’t need a separate process, a sidecar, or a subprocess dance. The same BEAM that’s serving your LiveViews serves your MCP endpoint, which is part of why Elixir is a genuinely good home for this. If you want the longer argument for that, I wrote it up in Why the BEAM is a great fit for AI agents and, more fundamentally, in the Elixir concurrency model.
The build: one server, one real tool, Streamable HTTP into Phoenix
Let’s expose exactly one tool — a read-only customer lookup — and
wire it into an existing Phoenix app. I’m using the Hermes server API as
documented for the current hermes_mcp release line (0.14 at
the time of writing). Add the dependency:
# mix.exs
defp deps do
[
{:hermes_mcp, "~> 0.14"}
# ... your existing deps
]
endThe tool component
In Hermes, each tool is its own module — a component. You
declare its type, define an input schema, and implement
execute/2. The schema is a small DSL that doubles as
runtime validation and the JSON Schema the model sees, which is
exactly the property you want: the contract the agent reads is the
contract your code enforces.
defmodule MyApp.MCP.Tools.CustomersOverLimit do
@moduledoc "List active customers currently over their plan's usage limit."
use Hermes.Server.Component, type: :tool
alias Hermes.Server.Response
alias MyApp.Billing
# This schema is the tool's typed boundary. It's validated before
# execute/2 ever runs, and it's what the model is shown as the
# tool's input contract. Keep it tight.
schema do
field :limit, :integer,
required: false,
description: "Max rows to return (1-100). Defaults to 25."
end
@impl true
def execute(params, frame) do
# The authenticated account was placed on the frame by the server's
# init/2 (see below). We scope EVERY query to it — the agent never
# gets to pick whose data it reads.
account = frame.assigns.current_account
row_limit = clamp(params[:limit] || 25, 1, 100)
# A named context function — NOT raw SQL. The query lives in your
# billing context, where it's tested and authorized like any other.
customers = Billing.customers_over_limit(account, limit: row_limit)
response =
Response.tool()
|> Response.json(%{
count: length(customers),
customers:
Enum.map(customers, fn c ->
%{
id: c.id,
name: c.name,
plan: c.plan_name,
usage_pct: c.usage_pct
}
end)
})
{:reply, response, frame}
end
defp clamp(n, lo, hi), do: n |> max(lo) |> min(hi)
endA few things worth noticing. The execute/2 callback
returns {:reply, response, frame} — the frame is threaded
through so any state you accumulated (the authenticated account, for
instance) stays available. Hermes.Server.Response gives you
tool/0 to start a response, then text/2,
json/2, and error/2 to build it up. I’m
returning structured JSON rather than prose, because a downstream agent
reasons better over data than over a paragraph.
And critically: this tool calls
Billing.customers_over_limit/2, a function in my own
context. It does not take a query string from the
model. More on why that matters shortly.
The server module
The server declares its identity, its capabilities, and which
components it exposes. It also runs init/2 once per
session, which is your hook for setup.
defmodule MyApp.MCP.Server do
use Hermes.Server,
name: "MyApp",
version: "1.0.0",
capabilities: [:tools]
alias MyApp.Accounts
# Register each tool component. Add only what you've deliberately
# decided to expose — this list IS your allow-list.
component MyApp.MCP.Tools.CustomersOverLimit
@impl true
def init(_client_info, frame) do
# Authenticate once per session, right here. The frame's transport
# carries the request's HTTP headers; we read the bearer token,
# resolve it to an account, and stash that on the frame so every
# tool can scope to it. A failed auth stops the session before any
# tool runs. (Why init/2 and not a plug? See the next section.)
with "Bearer " <> token <- get_in(frame.transport, [:headers, "authorization"]),
{:ok, account} <- Accounts.account_from_api_token(token) do
{:ok, assign(frame, current_account: account)}
else
_ -> {:stop, :unauthorized}
end
end
endAuthentication and transport, into the endpoint
Notice that the authentication lives in the server’s
init/2 above — not in a Phoenix plug. That distinction
trips people up, so it’s worth stating plainly: a
Plug.Conn assign does not reach a Hermes tool.
Hermes builds its own Frame from the transport’s metadata,
so an assign(conn, :current_account, ...) you set in a plug
before the forward never appears on
frame.assigns. The seam Hermes actually gives you is
init/2, where frame.transport exposes the
request headers — so that is where authentication belongs.
The good news is you almost certainly already have the mechanism.
MCP’s own auth story (OAuth-based) is still settling, but for a server
that lives inside your Phoenix app talking to your
internal agents, you have an API token or a signed header today. Read it
off the transport in init/2, resolve it to an account, put
the account on the frame, and let every tool read it from
frame.assigns — which is exactly what the server above
does.
That leaves the router with just the transport plug —
Hermes.Server.Transport.StreamableHTTP.Plug, pointed at
your server. No auth plug in the pipeline; init/2 owns that
now:
# lib/my_app_web/router.ex
pipeline :mcp do
plug :accepts, ["json"]
end
scope "/mcp" do
pipe_through :mcp
forward "/", Hermes.Server.Transport.StreamableHTTP.Plug,
server: MyApp.MCP.Server
endFinally, supervise the server. Hermes needs its registry, and the server runs as a supervised process with the Streamable HTTP transport selected:
# lib/my_app/application.ex
children = [
# ... Repo, Endpoint, PubSub, etc.
Hermes.Server.Registry,
{MyApp.MCP.Server, transport: :streamable_http}
]That’s the whole server. One supervised process, one plug forward,
one authenticated tool. Point an MCP client at
https://yourapp.com/mcp with a bearer token and Claude can
now ask your app, in your words, which customers are over their limit —
and the answer is computed live, scoped to the caller’s account, through
code you already test.
A note on API confidence. I verified these module and function names against the current Hermes documentation:
use Hermes.Serverwithname/version/capabilities, thecomponent/1macro,init/2,use Hermes.Server.Component, type: :tool, theschema do ... endDSL,execute/2returning{:reply, response, frame},Hermes.Server.Response(tool/0,text/2,json/2,error/2), authentication ininit/2readingframe.transportheaders, theHermes.Server.Frameassignsmap andassign/3,Hermes.Server.Registry, andHermes.Server.Transport.StreamableHTTP.Plugwith theserver:option. Hermes is moving quickly, so before you copy this into production, run it against the version you’ve pinned and check the server quick start — small naming and option details are the kind of thing that shifts between minor releases.
The safety boundary is the actual product
Everything above is plumbing. This section is the opinion, and it’s the reason I’d trust this pattern in production: you never hand an LLM raw access to anything.
The lazy version of an MCP server exposes a single
run_sql tool, hands the model your database connection, and
calls it “flexible.” It is flexible the way leaving your front door open
is flexible. An LLM is a probabilistic text generator that an attacker
can influence through the data it reads — a customer’s name, a support
ticket, a webhook payload. If the model can emit arbitrary SQL, then
anyone who can get text in front of the model can, in effect, run
arbitrary SQL. That’s prompt injection escalated to data exfiltration,
and no amount of “please only read” in the system prompt closes it.
So the boundary is a hard rule with four properties, and each tool must satisfy all four:
Tools, not raw access. Every tool maps to a named function in one of your contexts —
Billing.customers_over_limit/2, not a query the model assembles. The set of things the agent can do is exactly the set of tools you wrote, no more. This is an allow-list, and the allow-list is short by design.Authorization on every call. The account is established at the edge and scoped into every query. The model does not get to specify whose data it reads; that’s pinned from the authenticated session. This is the same discipline you already apply in a LiveView’s
handle_event— authorize the actor, scope the query — just enforced one layer further out.Validation at the boundary. The
schemablock validates inputs beforeexecute/2runs, and I still clamplimitto a sane range inside the tool. Treat tool arguments as exactly as untrusted as a form submission, because that’s what they are — a form filled out by a model that another party may be steering. Never build atoms from tool input, never interpolate it into a query, never trust a range.Auditability. Because every action is a discrete, named tool call, you can log it: who called, which tool, what arguments, what result size. That log is your incident timeline when something goes wrong, and it’s the thing a raw-SQL tool can never give you cleanly.
If a tool can’t satisfy all four, it doesn’t ship. The instinct to expose “just a little more” — a generic search, a flexible filter, a write tool with broad scope — is the instinct to resist. A small, boring, well-guarded toolset is the entire value proposition. The model’s cleverness is supposed to live in deciding which tool to call and how to use the result, not in inventing new capabilities you didn’t authorize.
A related discipline for write-heavy or long-running tools: don’t do
the work inline in execute/2. Enqueue an idempotent
background job and return a handle the agent can poll. That keeps the
tool call fast, gives you a retry boundary, and means a misbehaving
agent can’t pile up half-finished mutations. (I’ve got a dedicated post
on driving agent workloads through Oban coming soon — that’s where this
thread continues.)
When you should NOT build one yet
I’d be doing you a disservice if I made this sound like free money. Here’s when to wait:
You have exactly one consumer, and it’s your own code. MCP’s payoff is standardization — many hosts, one protocol. If the only thing calling your app is a script you also wrote, a plain function call or an internal API is simpler, faster, and has none of the protocol’s moving parts. Build the MCP server when you have multiple agent clients, or a client you don’t control (Claude Desktop, a teammate’s agent), or a genuine “any MCP host should be able to use this” requirement.
The spec is still moving and you can’t absorb churn. The transport already shifted from HTTP+SSE to Streamable HTTP. Auth is mid-evolution. If you’re shipping something that has to be stable for two years with no maintenance, you’re going to be chasing spec revisions. Fine for an internal tool you can update; risky for a fire-and-forget integration.
Your auth model isn’t ready for it. If you don’t already have a clean way to authenticate a non-browser, non-human caller and scope it to an account, build that first. The MCP server is only as safe as the boundary in front of it, and bolting auth on afterward is how you end up with the open-front-door version.
You can’t yet name the small set of tools. If your honest answer to “which tools?” is “I’m not sure, let’s expose a lot and see,” stop. The constrained allow-list isn’t a phase-two cleanup; it’s the design. A vague toolset is a security incident waiting for an excuse.
You don’t have an agent that benefits. MCP is infrastructure for agents. If nobody on your side is actually running an agent that would call these tools, you’re building a road to nowhere. Have the consumer first.
Verdict
Build an MCP server when you have a real agent consumer — especially one you don’t fully control — that would benefit from calling your app’s capabilities live, and when you already have an authentication boundary you trust. In Elixir, Hermes makes the mechanics genuinely pleasant: tools as components, one plug forward, supervised on the same BEAM that runs the rest of your app. The Streamable HTTP transport drops into a Phoenix router like any other route.
But treat the library as the easy 20%. The 80% is judgment: a short allow-list of typed, authorized, auditable tools that wrap named context functions, with validation at the boundary and not one inch of raw database access handed to the model. Get that part right and an MCP server is one of the highest-leverage things you can give an agent — a safe, live window into your real system. Get it wrong and you’ve built a remote code execution endpoint with a friendly name.
Start with one tool. Make it read-only. Scope it to the caller. Ship that, watch the logs, and only then add the second.