Sake
Put your Phoenix app in front of Claude, ChatGPT and every other MCP client, with your own users signing in.
Sake (酒) is a Model Context Protocol server, client and OAuth 2.1 authorization server for Elixir. It does three things other MCP libraries leave to you:
- Your login is the identity provider. Sake ships a complete OAuth 2.1 authorization server: discovery, PKCE, dynamic registration, consent, token rotation. A person connects Claude to your app, signs in with the account they already have, and every tool call knows who they are. If you already run Keycloak, Auth0, Okta or Entra ID, Sake checks their tokens instead.
- One endpoint serves every client. The stateless 2026-07-28 protocol and the older handshake-and-session revisions (2025-11-25, 2025-06-18, 2025-03-26) share one path. Old clients and new ones both connect.
- The client logs itself in.
Sake.Clientruns the whole MCP authorization flow against any server: discovery, registration, PKCE, refresh, step-up and DPoP. Your code doesn't build URLs or store tokens.
The official MCP conformance suite runs in CI on every commit (see Conformance). MIT licensed.
Quickstart
def deps do
[{:sake, "~> 0.6"}, {:bandit, "~> 1.5"}]
end
defmodule MyApp.MCP.Echo do
use Sake.Tool, name: "echo", description: "Returns the text it receives"
arg :text, :string, required: true
@impl true
def execute(%{text: text}, _ctx), do: {:ok, text}
end
defmodule MyApp.MCP do
use Sake.Server, name: "my-app", version: "1.0.0"
tools [MyApp.MCP.Echo]
end
# In your supervision tree:
{Sake, secret_key_base: MyAppWeb.Endpoint.config(:secret_key_base)}
# In your Phoenix router:
import Sake.Router
scope "/" do
sake_endpoint "/mcp", server: MyApp.MCP
end
Then connect a client:
claude mcp add --transport http my-app https://myapp.example.com/mcp
Or, on the same machine, over stdio:
claude mcp add --transport stdio my-app -- mix sake.stdio MyApp.MCP
mix dev in this repository starts a complete example app with a login
form, the OAuth endpoints, and sample tools, resources and prompts.
Require a signed-in user
Mark the scope a tool needs. Then turn on auth:, add the router
macros, and put your existing login pipeline in front of the authorize
page:
defmodule MyApp.MCP.CloseIssue do
use Sake.Tool, name: "close_issue", description: "Close an issue", scope: "issues:write"
arg :id, :integer, required: true
@impl true
def execute(%{id: id}, ctx) do
with {:ok, _issue} <- MyApp.Issues.close(ctx.scope, id), do: {:ok, "Closed ##{id}"}
end
end
{Sake,
secret_key_base: MyAppWeb.Endpoint.config(:secret_key_base),
auth: [
subject: MyApp.MCP.Subject, # reads the signed-in user from the conn
issuer: "https://myapp.example.com",
servers: [MyApp.MCP],
store: {Sake.Auth.Store.Ecto, repo: MyApp.Repo}
]}
scope "/" do
sake_well_known()
sake_endpoint "/mcp", server: MyApp.MCP, auth: true
end
scope "/oauth" do
pipe_through :api
sake_oauth_api()
end
scope "/oauth" do
pipe_through [:browser, :require_authenticated_user]
sake_oauth_authorize()
end
Now a client that connects gets a 401, finds your authorize URL, and
opens your login page. The person approves on a consent page and the
client gets tokens scoped to this endpoint. Each tool sees the user in
ctx.scope. A client without issues:write never sees close_issue.
Calling it anyway asks the client to step up.
The quickstart guide covers all five steps, including the subject module and the migration.
Test it like a controller
Sake.Test connects to your server in memory, so a test reads like
real client code. Argument validation, scope checks, errors, telemetry
and traces all run for real. Only HTTP is skipped.
import Sake.Test
test "a reader cannot close issues" do
client = connect(MyApp.MCP, scope: user_fixture(), scopes: ["issues:read"])
assert {:ok, tools} = list_tools(client)
refute "close_issue" in Enum.map(tools, & &1.name)
assert {:error, {:insufficient_scope, _}} = call_tool(client, "close_issue", %{"id" => 1})
end
Call other MCP servers
{:ok, session} = Sake.Client.connect("https://mcp.example.com/mcp")
{:ok, tools, session} = Sake.Client.list_tools(session)
{:ok, result, session} = Sake.Client.call_tool(session, "search", %{"query" => "x"})
When the server asks for authorization, the client runs the flow itself: discovery, Client ID Metadata Documents or dynamic registration, PKCE, pushed requests, refresh, and step-up. A service without a person at a browser can use client credentials, a JWT bearer grant, or enterprise-managed authorization from an OpenID Connect or SAML sign-on. See the client guide.
Proxy a third-party server behind your login
Put an upstream MCP server behind your own users, scopes and middleware, with per-user credentials:
defmodule MyApp.MCP.GitHub do
use Sake.Proxy,
name: "github",
version: "1.0.0",
upstream: "https://api.githubcopilot.com/mcp/",
tools: [except: ["delete_repository"]],
scope: "github",
scopes: %{"create_issue" => "github:write"}
@impl Sake.Proxy
def credentials(ctx) do
case MyApp.Accounts.github_token(ctx.scope) do
{:ok, token} -> {:bearer, token}
:error -> {:error, "Connect your GitHub account first."}
end
end
end
What's included
Protocol
- Tools, resources, resource templates, prompts and completion
- Elicitation (form and URL), sampling, roots, progress, logging
- Tasks (
io.modelcontextprotocol/tasks), MCP Apps (io.modelcontextprotocol/ui), Skills (io.modelcontextprotocol/skills) - Your own extensions (
Sake.Extension): methods, settings and notifications under an identifier of yours - Subscriptions:
subscriptions/listenon modern clients,resources/subscribeon older ones - Streamable HTTP and stdio, with JSON-RPC batching on 2025-03-26
Authorization server (Sake.Auth)
- OAuth 2.1 with PKCE, dynamic registration, Client ID Metadata Documents, consent, refresh token rotation
- Bearer or DPoP-bound tokens (RFC 9449), offered or required
- Machine clients: client credentials with a secret,
client_secret_jwtorprivate_key_jwt - Enterprise-managed authorization (ID-JAG) and workload identity
- Device authorization grant for CLIs, pushed authorization requests, OpenID Connect (ID tokens, userinfo)
- Step-up on how the person signed in, such as MFA or a recent sign-in (RFC 9470)
- Per-person scope limits and multi-tenant endpoints
Bring your own authorization server (Sake.Auth.External)
- JWT validation, token introspection, or your own callback, with scopes, step-up and DPoP
Building and running
Sake.Middlewarearound every call: audit, rate limits, plans, rewriting, redactionmountone server inside another under a prefix, including servers shipped in librariesSake.Proxyfor an HTTP or stdio upstream on either protocol era- OpenTelemetry traces from the client's call through the server into the tool
- Several nodes, with shared Ecto stores and
pubsub:for streams JSONorJasonfor your data
The protocol versions guide shows which features each revision supports.
Conformance
CI runs the official MCP conformance suite
(@modelcontextprotocol/conformance 0.2.0-alpha.12) on every commit.
It covers the server and client scenarios of 2025-06-18, 2025-11-25 and
2026-07-28, the authorization-server scenarios, and the extension
scenarios (tasks, skills, and client auth extensions including DPoP).
Every extension and auth scenario passes. Two core scenarios are expected to fail, and each has a documented reason in protocol versions:
| Scenario | Side | Why |
|---|---|---|
input-required-result-ignore-extra-params |
server | Answers without the signed request state are asked again |
elicitation-sep1034-client-defaults |
client | The client doesn't answer server requests on the standalone GET stream |
Performance
On a 4-vCPU cloud VM, with a tool that does nothing, so the numbers measure Sake:
- About 70 µs of CPU per tool call. Two cores answer about 8,500 calls a second over HTTP, with a p99 of 2.3 ms at 10 connections.
- About 20 KiB per open stream. One broadcast reaches 9,000 open streams in about a third of a second.
- Authorization adds 30–60 µs with the ETS store, cached introspection or your own callback, and more for signature checks (DPoP, JWTs) or a database token store.
The scripts are in bench/, so you can rerun them on your own hardware.
The performance guide has every result and the
method behind it.
Not there yet
- No Redis store. State lives in ETS or in your Ecto repo.
- Pre-1.0. The API can still change between minor versions. Each change is in the changelog.
Requirements
Elixir 1.18 or later on OTP 27 or later. CI runs Elixir 1.18 (OTP 27),
1.19 (OTP 28) and 1.20 (OTP 29). Phoenix, Ecto, Phoenix.PubSub, jose,
phoenix_html and opentelemetry_api are optional. Add the ones whose
features you use. Signed JWTs and DPoP need {:jose, "~> 1.11"}.
Guides
- Start: Quickstart · Tools · Resources and prompts · Testing
- Auth: Authorization · Bring your own authorization server · Multi-tenant endpoints · Security
- Protocol: Tasks · MCP Apps · Skills · Extensions · Protocol versions · Stdio
- Architecture: Middleware · Composing and proxying · Shipping a server in a library · The client
- Operations: Observability · Running on several nodes · Performance
Security
Report vulnerabilities privately, as SECURITY.md describes. Please don't open a public issue for one.
Contributing
CONTRIBUTING.md explains when a change needs an issue first and what a pull request needs.
License
MIT. See LICENSE.