Add the dependency
def deps do
[
{:sake, "~> 0.1"},
{:bandit, "~> 1.5"}
]
endbandit (or Phoenix's own endpoint) serves the HTTP; Sake is a Plug.
Start Sake
Add Sake to your supervision tree. Without auth:, only the stream
registry runs — good enough for a first server:
children = [
{Sake, secret_key_base: MyAppWeb.Endpoint.config(:secret_key_base)},
MyAppWeb.Endpoint
]secret_key_base: can be a binary of at least 32 bytes, or {m, f, a}
to read it lazily (from MyAppWeb.Endpoint.config/1, for instance). It
signs legacy session IDs and elicitation round-trips.
Write a server and a tool
defmodule MyApp.MCP do
use Sake.Server, name: "my-app", version: "1.0.0"
tools [MyApp.MCP.Echo]
end
defmodule MyApp.MCP.Echo do
use Sake.Tool, name: "echo", description: "Returns the text it receives"
arg :text, :string, required: true, description: "The text to return"
@impl true
def execute(%{text: text}, _ctx), do: {:ok, text}
endSake.Server builds the tool list, capability negotiation and dispatch;
Sake.Tool compiles arg lines into a JSON Schema and validates every
call before execute/2 runs. See guides/tools.md for arguments, results,
streaming and elicitation, and guides/resources_and_prompts.md for
resources and prompts.
Mount the endpoint
In a Phoenix router:
import Sake.Router
scope "/" do
forward "/mcp", Sake.Plug, server: MyApp.MCP
endOr with sake_endpoint, which also works under a scope with a dynamic
segment (see guides/tenants.md):
scope "/" do
sake_endpoint "/mcp", server: MyApp.MCP
endIn a bare Plug app, forward works the same way:
forward "/mcp", to: Sake.Plug, init_opts: [server: MyApp.MCP]The endpoint speaks the modern (2026-07-28) protocol and the three
handshake-era revisions (2025-11-25, 2025-06-18, 2025-03-26) on the same
path — a client picks whichever it supports; see
guides/protocol_versions.md.
Connect a client
claude mcp add --transport http my-app https://myapp.example.com/mcpThe client sends tools/list, then tools/call with {"name": "echo", "arguments": {"text": "hi"}}, and gets back {"content": [{"type": "text", "text": "hi"}]}.
Turn on authorization
An unauthenticated endpoint is fine for an internal tool; anything a person connects a client to should require a token. Five steps:
A subject module — how Sake reads and loads the signed-in person:
defmodule MyApp.MCP.Subject do @behaviour Sake.Auth.Subject @impl true def from_conn(conn) do case Plug.Conn.get_session(conn, :user_id) do id when is_binary(id) -> MyApp.Accounts.fetch(id) _ -> :error end end @impl true def id(user), do: user.id @impl true def fetch(id), do: MyApp.Accounts.fetch(id) @impl true def display(user), do: user.email endA migration, if you want tokens to survive a restart (the default ETS store does not):
defmodule MyApp.Repo.Migrations.AddSakeAuth do use Ecto.Migration def up, do: Sake.Auth.Migration.up() def down, do: Sake.Auth.Migration.down() endConfiguration on the
Sakechild:{Sake, secret_key_base: MyAppWeb.Endpoint.config(:secret_key_base), auth: [ subject: MyApp.MCP.Subject, issuer: "https://myapp.example.com", servers: [MyApp.MCP], store: {Sake.Auth.Store.Ecto, repo: MyApp.Repo} ]}The router macros — discovery, the token/register/revoke API, and the authorize page behind your existing login pipeline:
scope "/" do sake_well_known() end scope "/oauth" do pipe_through :api sake_oauth_api() end scope "/oauth" do pipe_through [:browser, :require_authenticated_user] sake_oauth_authorize() endauth: trueon the endpoint:sake_endpoint "/mcp", server: MyApp.MCP, auth: true
That is a full OAuth 2.1 authorization server: PKCE, dynamic client
registration, Client ID Metadata Documents, a consent page, and token
rotation. See guides/authorization.md for the flow end to end.
Try it locally
This repository's dev/ directory is a small but complete app: mix dev
starts it on http://localhost:4000, with a login form, the OAuth
endpoints, and a few example tools, resources and prompts. Read
dev/lib/sake_dev/router.ex and dev.exs alongside this guide — they are
the shortest path from zero to a signed-in tools/call.