ZenStack → MCP in under 10 lines of code
Expose your ZenStack v3 database to LLM like Claude — with access-control policies enforced on every query, and OAuth 2.0 authentication handled out of the box.
const { oauthRoutes, mcpMiddleware } = createHonoMcpHandler({
schema,
auth: { validateCredentials, jwtSecret: process.env.JWT_SECRET! },
getClient: async (user) => db.$use(new PolicyPlugin()).$setAuth({ id: user.id }),
})
app.route('/', oauthRoutes) // OAuth 2.0 + PKCE flows
app.route('/mcp', mcpMiddleware)claude mcp add --transport http zenstack-mcp http://localhost:3000That's it — Claude can now query and mutate your database, and your ZenStack policies decide what each user can see or do.
AI assistants need structured access to your data. Giving them raw database access is unsafe; building a custom API for each use-case is slow. zenstack-mcp bridges the gap:
- Policy-enforced queries — every MCP call goes through your ZenStack access-control rules, the same ones protecting your regular API
- Standard auth — OAuth 2.0 + PKCE built in, so Claude Code and Claude Desktop authenticate with a browser flow and store tokens automatically
- Schema-aware — the
schematool gives the AI your full data model; it asks better questions and writes better queries - Zero boilerplate — one function call wires up the MCP server, OAuth endpoints, and policy enforcement
Claude (MCP client)
│ OAuth 2.0 + PKCE
▼
┌─────────────────────────────┐
│ zenstack-mcp server │
│ │
│ • schema tool │ ← returns your ZenStack data model
│ • execute tool │ ← runs Prisma queries through policies
│ • procedure tool │ ← invokes custom $procs through policies
│ • me tool │ ← returns the authenticated user
└────────────┬────────────────┘
│ db.$use(new PolicyPlugin()).$setAuth({ id: user.id })
▼
┌─────────────────────────────┐
│ ZenStack enhanced client │ ← access-control policies enforced here
└────────────┬────────────────┘
│
▼
Your database
Each request is authenticated, the user is resolved from the token, and the ZenStack client is scoped to that user before any query runs. Policies declared in your .zmodel file are evaluated automatically — no extra code needed.
packages/
zenstack-mcp/ # Core library — install this in your project
examples/
express-builtin/ # Express + built-in OAuth 2.0 + PKCE
hono-better-auth/ # Hono + better-auth (bearer token)
The publishable package. Provides:
createHonoMcpHandler/createExpressMcpHandler— one-call server setup- Built-in OAuth 2.0 + PKCE (login page, token endpoint, PKCE validation)
betterAuthMcpAdapter— plug in an existing better-auth instance@@mcp(false)plugin attribute — hide sensitive models from AI clientsgetRequestUser()— access the authenticated user from any handler
→ See packages/zenstack-mcp/README.md for the full API reference.
Minimal Express server with the built-in OAuth flow. Good starting point if you don't already have an auth layer.
→ See examples/express-builtin/README.md
Hono server that delegates auth to a better-auth instance. The bearer plugin lets the session token be used directly as a Bearer header — no extra token layer.
→ See examples/hono-better-auth/README.md
bun add zenstack-mcp
# or
npm install zenstack-mcp// With Hono
import { createHonoMcpHandler } from 'zenstack-mcp/server-adapters/hono'
import { schema } from '~/zenstack/schema'
import { db } from '~/db'
import { PolicyPlugin } from '@zenstackhq/plugin-policy'
const { oauthRoutes, mcpMiddleware } = createHonoMcpHandler({
schema,
auth: {
validateCredentials: async (email, password) => {
const user = await db.user.findUnique({ where: { email } })
if (!user || !await bcrypt.compare(password, user.passwordHash)) return null
return user
},
jwtSecret: process.env.JWT_SECRET!,
},
getClient: async (user) => db.$use(new PolicyPlugin()).$setAuth({ id: user.id }),
})
app.route('/', oauthRoutes)
app.route('/mcp', mcpMiddleware)// schema.zmodel
plugin mcp {
provider = 'zenstack-mcp'
output = './zenstack'
}
model AuditLog {
id String @id
@@mcp(false) // hidden from AI clients
}Run zen generate, then pass mcpConfig to the handler.
# Claude Code CLI
claude mcp add --transport http zenstack-mcp http://localhost:3000Or add to claude_desktop_config.json for Claude Desktop.
By default, the built-in OAuth flow serves a minimal login page. Replace it with your own HTML:
import { readFile } from 'fs/promises'
createHonoMcpHandler({
auth: {
// String
loginPage: '<html>...</html>',
// Or an async function (fetched from disk, a CMS, etc.)
loginPage: () => readFile('./login.html', 'utf-8'),
validateCredentials,
jwtSecret: process.env.JWT_SECRET!,
},
// ...
})The login script is injected automatically before </body>. Your HTML must expose these element IDs so the script can wire up the form:
| ID | Element | Purpose |
|---|---|---|
login-form |
<form> |
Submit listener |
email |
<input type="email"> |
Email field |
password |
<input type="password"> |
Password field |
login-button |
<button type="submit"> |
Disabled during submission |
error-container |
any element | Displays error messages (hidden by default) |
If you need to inject the script yourself (e.g. with a CSP nonce), import it directly:
import { loginScript } from 'zenstack-mcp'
const html = `<html>
...
<script nonce="${nonce}">${loginScript}</script>
</body></html>`Via code — include / exclude accept model name lists:
createHonoMcpHandler({
schema,
include: ['Post', 'Comment'], // only these models
// or
exclude: ['AuditLog', 'Session'],
// ...
})Via ZenStack plugin — annotate models in schema.zmodel with @@mcp(false), then pass the generated config:
plugin mcp {
provider = 'zenstack-mcp'
output = './zenstack'
}
model AuditLog {
id String @id
@@mcp(false) // hidden from AI clients
}import { mcpConfig } from '~/zenstack/mcp-config'
createHonoMcpHandler({ schema, mcpConfig, ... })Whitelist which Prisma operations the AI can call on each model:
createHonoMcpHandler({
schema,
modelOperations: {
Post: ['findMany', 'findUnique', 'create'], // no update / delete
User: ['findMany', 'findUnique'], // read-only
},
// ...
})Enable requireWhereForBulk to reject deleteMany / updateMany calls that have no (or empty) where clause. Useful as a safety net against LLMs accidentally wiping entire tables:
createHonoMcpHandler({
schema,
requireWhereForBulk: true,
// ...
})createHonoMcpHandler({
schema,
auth: {
validateCredentials,
jwtSecret: process.env.JWT_SECRET!,
tokenTtl: 900, // access token TTL — 15 min (default: 3600)
refreshTokenTtl: 604800, // refresh token TTL — 7 days (default: 30 days)
tokenStore: myRedisStore, // custom store for multi-process / serverless
},
// ...
})tokenStore must implement the TokenStore interface (see packages/zenstack-mcp/README.md). The default in-memory store is not suitable for multi-process or serverless deployments.
By default anyone can POST /register to obtain a client_id. In production, require an initial access token:
createHonoMcpHandler({
auth: {
validateCredentials,
jwtSecret: process.env.JWT_SECRET!,
initialAccessToken: process.env.MCP_REGISTRATION_SECRET!,
maxClients: 50, // hard cap on registered clients (default: 100)
},
// ...
})MCP clients must then send Authorization: Bearer <initialAccessToken> when registering.
createHonoMcpHandler({
schema,
transport: 'both', // 'streamable-http' (default) | 'sse' | 'both'
// ...
})| Transport | Endpoint | Best for |
|---|---|---|
streamable-http |
POST /mcp/ |
Claude Code, most MCP clients |
sse |
GET /mcp/sse |
Legacy / browser-based clients |
both |
both above | Maximum compatibility |
Express only supports
streamable-http. Use the Hono adapter for SSE.
Two things matter when you deploy on a multi-instance / serverless runtime (Cloudflare Workers, Lambda, autoscaled containers): how the better-auth instance is obtained, and how its database connections behave across requests.
createHonoMcpHandler builds its OAuth + MCP routes once at startup — there is no per-request hook to rebuild the handler. But betterAuthMcpAdapter accepts the auth in two forms that differ in when the better-auth instance is materialized.
A captured instance — created once at module load:
const auth = betterAuth({ /* ... */, secret: process.env.BETTER_AUTH_SECRET })
const { oauthRoutes, mcpMiddleware } = createHonoMcpHandler({
schema,
mcpConfig,
auth: betterAuthMcpAdapter(auth), // ← reused for the isolate's lifetime
getClient: async (user) => createUserDb(user.id),
})A per-request factory — pass getAuth plus the static baseURL / secret. getAuth is invoked to build a fresh instance only when a better-auth API call is actually needed (signInEmail on login, getSession on refresh / stateful validate) — never on the stateless validateToken hot path:
const { oauthRoutes, mcpMiddleware } = createHonoMcpHandler({
schema,
mcpConfig,
auth: betterAuthMcpAdapter({
getAuth: () => createAuth(createDb()), // ← fresh, request-scoped db each call
baseURL: process.env.BETTER_AUTH_URL!,
secret: process.env.BETTER_AUTH_SECRET!,
}),
getClient: async (user) => createUserDb(user.id),
})The captured instance is a small performance win — validateToken runs on every tool call, and reusing the instance avoids repaying better-auth's setup cost (key imports, JWT signing, rebuilding the plugin chain). But that instance, and any DB connection it holds, lives for the whole isolate — which runs into the workerd rule below.
The factory form trades that micro-optimization for a cleaner lifetime: the only DB work happens inside the request that called getAuth, with a request-scoped client, so nothing outlives the request. On serverless, prefer the factory form — it sidesteps the gotcha below entirely. (In stateless mode validateToken is pure crypto and calls neither getAuth nor the database, so the hot path stays cheap either way.)
Whichever auth form you use, no caller identity ever flows through it. A shared server is safe across users only because identity is strictly per-request:
- Each request presents its own token →
validateTokenreturns the user encoded in that token (pure crypto in stateless mode — a user can only ever obtain the identity signed into their own token). - That user is stored in a per-request
AsyncLocalStoragecontext (requestContext.run({ user }, …)), so concurrent requests never see each other's user. - The
executetool readsgetRequestUser()from its context and callsgetClient(user)— which must return a fresh, policy-scoped client every time.
The singleton holds no per-user state; the only mutable thing the requests share is the auth machinery, which is request-driven (input in, result out). That is exactly why better-auth is designed to be a singleton.
The one thing that breaks this is making getClient return anything that isn't scoped to the user argument:
// ✅ a fresh, per-user, policy-scoped client on every call
getClient: async (user) =>
createDb().$use(new PolicyPlugin()).$setAuth({ id: user.id })
// ❌ returns the raw singleton client — NO policies, every user sees everything
getClient: async () => longLivedDb
// ❌ caches one client across requests — leaks whichever user set it last
let shared
getClient: async (user) => (shared ??= createDb().$use(new PolicyPlugin()).$setAuth(user))| Rule | Why |
|---|---|
Build the client from the user argument, every call |
Scopes ZenStack policies (auth().id / auth().role) to the caller |
| Never reuse / memoize the returned client across requests | A cached client carries the previous caller's $setAuth identity |
Never expose the singleton's raw (unscoped) auth client via getClient |
It bypasses policies entirely |
Hide auth tables with @@mcp(false) |
Keeps Session / Account / Verification off the AI surface |
ZenStack's
$setAuthreturns a new client instance rather than mutating in place, so building per request is cheap and never races with a concurrent request.
On Cloudflare Workers (workerd) there is a hard runtime rule:
An I/O object — including a database connection/socket — created in the context of one request cannot be reused by a later request. Doing so aborts with "Cannot perform I/O on behalf of a different request."
If you use the factory form above, this doesn't apply: getAuth builds its db inside the request that needs it, so the connection is born and dies in one request — exactly like the per-request client your getClient returns. You're done.
It only bites the captured-instance form: that instance holds one db client (the one you passed to betterAuth({ database })) for the whole isolate. A normal connection pool keeps sockets alive between requests, so the second request that checks out a pooled connection inherits one bound to the first request's I/O context — and workerd kills it. If you must keep a captured instance, give its client a pool that never carries a connection across requests:
import { Pool } from 'pg'
// Safe to hold for the whole isolate lifetime: maxUses:1 destroys each connection
// right after it is released, so no socket is ever reused by a different request.
const longLivedPool = new Pool({
connectionString,
maxUses: 1,
idleTimeoutMillis: 1,
})Use
maxUses: 1only on a long-lived client — never on your per-request ones. It is a deliberate de-optimization: destroying the connection on release forces a brand-new connection per query instead of reusing one across the queries in a request. On a long-lived client that's touched rarely (e.g. an MCP auth singleton) the cost is negligible and worth the safety. On your hot per-request clients (the onegetClientbuilds, your API handlers, etc.) it is pure overhead for zero safety gain — those clients already can't reuse a socket across requests, because their whole pool is born and dies inside one request. Leave them on the default pool so multiple queries in a request share one connection.
maxUses: 1 is independent of which transport you pick — it just guarantees no socket outlives the request that opened it. Choose a Workers-compatible transport for that pool:
- Hyperdrive + a standard driver (
node-postgres/Postgres.js) — what Neon officially recommends for Workers. Point the pool atenv.HYPERDRIVE.connectionString; Hyperdrive keeps its own global pool to your database, so each new local socket still avoids the expensive Neon-side connection (you pay a cheap local handshake per query, not a full DB connect — fine for a rarely-touched long-lived client). Do not combine Hyperdrive with the Neon serverless driver (it speaks WebSocket/HTTP, not the TCP Hyperdrive proxies) or with Neon's-poolerendpoint (redundant — Hyperdrive already pools). - Neon serverless driver — the alternative when you're not using Hyperdrive. It tunnels over WebSocket/fetch (which workerd supports, unlike raw TCP sockets) and connects straight to Neon. Its
Poolis still a real pool, so set the samemaxUses: 1.
// Recommended on Workers when you have Hyperdrive:
import { Pool } from 'pg'
const longLivedPool = new Pool({
connectionString: env.HYPERDRIVE.connectionString,
maxUses: 1,
idleTimeoutMillis: 1,
})Either way, the rule is the same: a long-lived (singleton) DB client on workerd must not reuse a connection across requests.
In stateless mode the singleton's DB is barely touched — only
signInEmailduring the interactive login hits it (validateTokenis crypto-only) — but that one path is enough to trigger the error, so the long-lived client still needs the connection-per-request pool.
On multiple instances, /register and /oauth/authorize can land on different instances. Set a secret on your better-auth config (BETTER_AUTH_SECRET) so betterAuthMcpAdapter runs in stateless mode — client_ids are HMAC-signed and PKCE codes are encrypted into the code itself, so no shared in-memory map is needed. See packages/zenstack-mcp/README.md for the stateful/stateless trade-offs.
For the same reason, stick to the default
streamable-httptransport on Workers.ssestores sessions in a per-instance in-memory map and breaks across instances.
bun install
bun run build # build all packages
bun test # run tests (bun test)To run an example:
cd examples/hono-better-auth
bun install
bun run db:generate && bun run db:push
bun devMIT