A SaaS app usually wants one MCP endpoint per organization, not one endpoint shared by everyone. Sake does this with one path per tenant (each its own OAuth resource) rather than a single path that branches on a request parameter — a bearer token is scoped to the exact resource URL it was issued for, so a token minted for one tenant's endpoint simply does not work against another's.
One endpoint per tenant path
scope "/:org_slug" do
sake_endpoint "/mcp", server: MyApp.MCP, auth: true
endsake_endpoint compiles to Phoenix.Router.match(:*, ...), which — unlike
forward — accepts a dynamic segment in its scope. A Plug-only host
cannot express a dynamic segment this way (Plug.Router's forward takes
a literal prefix); mount one forward per known tenant instead, as the
development router in dev/lib/sake_dev/router.ex does for /acme/mcp
and /beta/mcp.
The canonical resource
The token audience Sake checks on every request is the endpoint's own
canonical URL: origin plus request path, e.g.
https://app.example.com/acme/mcp. A token minted for .../acme/mcp
gets a 401 against .../beta/mcp even with an otherwise-valid signature
and an unexpired lifetime — the bearer check compares
the stored token's resource against the endpoint's own canonical URL for the exact
conn it is serving.
resources: patterns
The authorization server needs to know which resource URLs it is allowed
to issue tokens for; list them (relative to the issuer) with :-prefixed
segments as tenant placeholders:
auth: [
...,
resources: ["/mcp", "/:org_slug/mcp"]
]An authorize request's resource parameter (RFC 8707) must match one of
these patterns (segment-for-segment, a :-prefixed pattern segment
matching anything) or the request is rejected with invalid_target. The
resource parameter is required on every authorize request unless
resources: names exactly one pattern with no placeholder at all — a
single-tenant server can omit it, since there is only one resource it
could mean. At the token endpoint resource is optional: the code or
refresh token already carries it, and a resource that is given must
match it.
Tokens per tenant
Because the resource is part of the token record, a client that talks to
two tenants (an integration platform connecting several of your
customers, say) needs a separate authorization for each: separate tokens
and a separate consent, since a remembered consent is kept per person,
client and resource.
Sake.Auth.list_grants/2 and revoke_grant/3 (see
guides/authorization.md) work the same way per subject regardless of how
many tenant resources that subject holds tokens for — each shows up as
its own row.
Who may authorize which tenant
Two checks keep tenants apart, and a multi-tenant host needs both:
- The audience check (above) stops token reuse across tenants: a
token issued for
/acme/mcpnever works against/beta/mcp. authorize_resource?/2stops Sake from issuing a token for a tenant the person does not belong to. Without it, any signed-in person could authorize a client for any organization's endpoint.
The authorize request happens at /oauth/authorize, outside any tenant
path, so the tenant is not in conn.params there: it is in the resource
URL the client asked for. authorize_resource?/2 receives the signed-in
person and that URL; return false and the client gets access_denied
("You may not authorize this resource"):
defmodule MyApp.MCP.Subject do
@behaviour Sake.Auth.Subject
@impl true
def from_conn(conn) do
case conn.assigns[:current_scope] do
%{user: user} -> {:ok, user}
_ -> :error
end
end
@impl true
def id(user), do: user.id
@impl true
def fetch(id), do: MyApp.Accounts.fetch_user(id)
@impl true
def display(user), do: user.email
# "https://app.example.com/acme/mcp" -> "acme"
@impl true
def authorize_resource?(user, resource) do
case URI.parse(resource).path |> String.split("/", trim: true) do
[org_slug, "mcp"] -> MyApp.Accounts.member?(user, org_slug)
_ -> false
end
end
endThe principal a tool sees
The Sake.Auth.Subject behaviour's fetch/1 returns whatever principal
your app already uses; nothing about tenancy is baked into it. On every
MCP request the tenant is in the request path, so the place to attach the
organization is scope: (the Sake.Plug option, not an OAuth scope),
which turns the conn into what a tool sees as ctx.scope. The default is
conn.assigns[:sake_scope], set from the bearer token's principal;
return the organization alongside the person there (the conn's
path_params carry the org slug), and every tool knows which tenant it
is serving without re-deriving it itself.