# Enforce, Don't Issue: Remote MCP with OAuth Without Becoming an Authorization Server

> Source: <https://dev.to/takashimatsuyama/enforce-dont-issue-remote-mcp-with-oauth-without-becoming-an-authorization-server-31gh>
> Published: 2026-08-19 10:56:02+00:00

[Kozou](https://kozou.org) is an open-source tool that exposes structured context from your PostgreSQL database to AI agents — over MCP and a REST API. This post is the record of a design decision: what happened when that MCP surface moved to a remote URL and needed OAuth. The capability shipped in v1.13.0, but the release is not the subject — **why Kozou never grew its own authorization server** is. Everything below is as of **v1.19.0**.

The moment you put an MCP server on a remote URL, authentication stops being a footnote and becomes the whole question. Kozou's own "before" state was honest about it: with no auth block configured, a remote call had exactly one shape available — every caller shared a single fixed `execution.role`

. That's fine for a local, single-user setup and useless for anything multi-tenant, because there's no such thing as *who* is calling. Kozou v1.13.0 is the release where per-caller identity becomes possible at all.

[Kozou v1.13.0](https://github.com/kozou-dev/kozou/releases/tag/v1.13.0) (released 2026-07-13) brought **remote MCP with OAuth (resource-server mode)**. The MCP transport can now authenticate callers with OAuth, so **the execution tool that touches data ( call) runs as the verified token's role** rather than one shared role. The tools that describe the schema are gated on the scopes the token carries, and read a shared schema context.

The mode matters, and it is the whole point of this post. Kozou did **not** grow its own authorization server. It became an OAuth *resource server*: it validates the tokens someone else issued and enforces what they're allowed to do. The setup guide lives at [kozou.org/guides/mcp-oauth/](https://kozou.org/guides/mcp-oauth/), and I'm deliberately not going to reproduce it here — this post is about *why* the design looks the way it does, not how to configure it.

The tempting story is "the MCP spec says split the roles, so Kozou split the roles." That's backwards. The posture was published first.

On 2026-06-08 — more than a month before v1.13.0 — Kozou's [auth posture](https://kozou.org/concepts/auth-posture/) was already public and settled: **Kozou is an enforcement layer; it does not issue identity.** That wasn't an aspiration. **A week earlier the REST surface had already shipped it**: JWT auth and Postgres RLS enforcement landed in [#54](https://github.com/kozou-dev/kozou/pull/54) on 2026-06-01 and went out in **v0.2.0** — validate a JWT against a JWKS endpoint, `SET LOCAL ROLE`

to the identity that token carries, and let Postgres row-level security do the actual enforcement.

So when remote MCP needed authentication, there was no design decision to agonize over. The resource-server shape wasn't chosen to conform to a spec — the shape was **already there**, and the [MCP authorization spec, revision 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) happens to describe exactly that split: a resource server that enforces, an authorization server that issues, and a clean boundary between them. v1.13.0 is the JWT → `SET LOCAL ROLE`

→ RLS pipeline, already load-bearing on REST, extended onto the MCP transport. Same posture, one more surface.

The order is the argument. The implementation on 2026-06-01, the posture written down on 06-08, the extension onto MCP on 07-13 — **code first, words second, the new surface last.** "Not chosen to conform to a spec" means exactly that sequence: something already running turned out to match what a later spec described.

Declining to be an authorization server is not a gap in the feature set. It's what makes the rest coherent:

`mcp:*`

scopes, and a role claim into the token. Getting that wrong is the first thing people trip on — it is the 403 below.`call`

. The transport changes; the rules don't.That last point is the quiet payoff. When identity is enforced at the database rather than re-implemented per transport, adding a surface doesn't mean re-deriving your access model — it inherits it.

Here's the part that's easy to miss: the MCP surface is not a copy of the REST surface with a token check bolted on. It is deliberately narrower about what it will accept.

The reason is not "the caller is remote and probably not a human." Put it there and the argument collapses — the person driving claude.ai is a human, and not one of these rules relaxes for them. Two other things drive it, and both have the same shape: **one more thing you do not control.**

First, **you hand your tokens and your advertised metadata to a client you don't run.** A remote MCP caller is a hosted client — claude.ai, ChatGPT, Claude Code — the protected-resource metadata Kozou publishes travels to it, and bearer tokens travel to the URLs that metadata names.

Second, **you are not the one granting roles.** Identity arrives from a federated directory: put Google Workspace behind Keycloak or Auth0, and every first-time user is a principal who authenticated fine and whom nobody assigned a role to.

On REST, Kozou will honor an anonymous role and a default role if you configure them. On MCP it deliberately won't — this is where the second reason bites:

`execute`

requires a non-empty `allowedRoles`

.The remaining rules have no counterpart on REST at all. They come from the first reason — the tokens and metadata you hand to somebody else's client:

`http`

outside loopback.`auth.resource`

and `auth.authorizationServers`

are handed to third-party clients in the protected-resource metadata, and bearer tokens travel to them, so a non-loopback plaintext `http`

URL isn't a warning — it's a startup error. (There's an explicit opt-in for an isolated test network, `allowInsecureHttp`

; using it logs a startup warning.) `Host`

header.`Host`

header for the kind of decision a DNS-rebinding attack would try to bend.`mcp:admin`

is a default scope, and is never advertised.`scopes_supported`

.The strictness isn't only about refusing things, though. When a token is missing a scope, Kozou (as a resource server) answers with an `insufficient_scope`

challenge that names what's missing — a `scope`

and a `resource_metadata`

pointer — so a client capable of scope upgrade can then go re-authorize. The scopes it advertises are `mcp:describe`

and `mcp:execute`

. In practice this challenge shows up most often when **a token carrying no recognized scope at all is refused at the door** — usually a setup mistake: the IdP's mapper isn't emitting the scope claim, the audience is wrong, or the token was minted for a different client. With `auth.resource`

set to `https://mcp.example.com/mcp`

, that looks like this:

```
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="mcp:describe", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
Content-Type: application/json

{"error":"This operation requires the \"mcp:describe\" scope."}
```

The same challenge exists per tool, but it is far rarer. `tools/list`

is filtered by scope — a tool whose scope the token lacks is never listed — so a per-tool 403 only happens if a client calls a name it was never shown, or cached `tools/list`

and then had its scopes narrowed.

And the matching case with no credentials at all — per RFC 6750, a 401 with no `error`

attribute, carrying a pointer to where the rules are advertised:

```
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
Content-Type: application/json

{"error":"Missing or malformed Authorization header."}
```

Both answers carry the same shape of JSON body. But when it is the token itself that failed, **the body never says which check it failed** — signature, expiry, audience, issuer all collapse into one generic message. A missing scope, which the client can resolve by re-authorizing, is named; a failed verification is not. Only what the client can act on goes in the `WWW-Authenticate`

header.

That's the shape worth internalizing: refusal and *discoverability* are two halves of the same behavior. A strict server that only ever said "no" would be hostile; one that says "no, and here's precisely the scope you'd need and where to find the metadata" is strict *and* usable. On the discovery side, Kozou implements both the `WWW-Authenticate`

and the well-known metadata discovery mechanisms, so a client can find the rules either way.

One of the things resource-server mode settled is that **the resource URI is declared in configuration, never derived** — because, as above, the `Host`

header can't be trusted. That refusal to leave things implicit later spread to the deployments that use no OAuth at all.

It started with a mundane confusion. The port you bind is where *you* are listening. It is not where the client arrives. Those two agree only while nothing sits between them — put a proxy in front, run it through a tunnel, remap the published port, move it inside a devcontainer, and the agreement breaks. Kozou's connection page — the screen that hands a non-engineer a working config — was building the second address out of the first, with no way to correct it once they came apart. It would hand out, confidently, an address that was not the endpoint.

The OAuth side already had the answer. `auth.resource`

is a declared value, never derived from a `Host`

header. v1.18.0's `server.mcp.http.advertisedUrl`

extends that to the deployments that don't configure `auth`

(writing both is a config error). A line drawn against spoofing turned out to be right where nobody was attacking anything — where there was simply one proxy in the way. An unplanned dividend on a rule drawn for strictness.

There is a second one that looks like the same story. v1.17.0's `server.mcp.http.enabled`

(default true) lets an operator declare that the MCP HTTP endpoint **should not run**. Until then the only lever was the bind address: the endpoint stayed up and its posture depended entirely on network topology. If you are never going to point an agent at it, the choice should be not to run it — not to look for somewhere to hide it.

That one, though, did not descend from OAuth. Its motivation at the time was that the posture had become a byproduct of network topology, which has nothing to do with a spoofable header. **Not a descendant of the same rule — a different rule standing next to it.** Put side by side they do face the same way, turning something implicit into something declared. I'll leave it at that.

None of this is free, and a post that pretended otherwise wouldn't be worth reading:

The minimal setup, the per-IdP recipes, and the troubleshooting are all in one place: [kozou.org/guides/mcp-oauth/](https://kozou.org/guides/mcp-oauth/). There's no point in my retyping the steps — the guide is the source of truth for *how*.

`SET LOCAL ROLE`

→ Postgres RLS — the MCP surface can afford to be `advertisedUrl`

(v1.18.0, for the deployments that don't set `auth`

— the config refuses the two together) handed the other postures an answer the OAuth side already had. A line drawn for strictness turned out to be the correct answer elsewhere.It was written with the help of [basou](https://basou.dev), a harness I'm building for steering AI coding agents.
