cd /news/ai-agents/enforce-don-t-issue-remote-mcp-with-… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-102793] src=dev.to β†— pub= topic=ai-agents verified=true sentiment=Β· neutral

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

Kozou, an open-source tool that exposes PostgreSQL context to AI agents, has extended its OAuth-based resource-server model to remote MCP connections in v1.13.0. The design deliberately avoids becoming an authorization server, instead validating tokens issued by others and enforcing permissions via Postgres row-level security. This approach, first implemented on the REST API in v0.2.0, ensures consistent access control across all surfaces.

read9 min views1 publishedAug 19, 2026

Kozou 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 (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/, 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 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 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 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/. 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, a harness I'm building for steering AI coding agents.

── more in #ai-agents 4 stories Β· sorted by recency
── more on @kozou 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/enforce-don-t-issue-…] indexed:0 read:9min 2026-08-19 Β· β€”