zenstack-mcp
v0.2.6
Published
Turnkey MCP (Model Context Protocol) server for ZenStack v3 with OAuth 2.0 authentication
Maintainers
Readme
zenstack-mcp
Turnkey MCP (Model Context Protocol) server for ZenStack v3, with built-in OAuth 2.0 authentication.
Exposes your ZenStack schema as MCP tools (schema, execute, procedure) so AI assistants like Claude can query and mutate your database — and invoke your custom procedures — through your access-control policies.
Installation
npm install zenstack-mcp
# or
bun add zenstack-mcpQuick Start
With Hono (built-in OAuth)
import { Hono } from 'hono'
import { createHonoMcpHandler } from 'zenstack-mcp/server-adapters/hono'
import { schema } from '~/zenstack/schema'
import { db } from '~/db'
import { PolicyPlugin } from '@zenstackhq/plugin-policy'
import bcrypt from 'bcryptjs'
const app = new Hono()
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) // /.well-known/*, /oauth/*, /login, /register
app.route('/mcp', mcpMiddleware)
export default appWith Express (built-in OAuth)
import express from 'express'
import { createExpressMcpHandler } from 'zenstack-mcp/server-adapters/express'
import { schema } from '~/zenstack/schema'
const app = express()
const { oauthRoutes, mcpMiddleware } = createExpressMcpHandler({
schema,
auth: {
validateCredentials: async (email, password) => { /* ... */ },
jwtSecret: process.env.JWT_SECRET!,
},
getClient: async (user) => db.$use(new PolicyPlugin()).$setAuth({ id: user.id }),
})
app.use(oauthRoutes) // /.well-known/*, /oauth/*, /login, /register
app.use('/mcp', mcpMiddleware)With better-auth
import { betterAuthMcpAdapter } from 'zenstack-mcp/auth-adapters/better-auth'
app.route('/mcp', createHonoMcpHandler({
schema,
auth: betterAuthMcpAdapter(myBetterAuthInstance),
getClient: async (user) => db.$use(new PolicyPlugin()).$setAuth({ id: user.id }),
}))ZenStack Plugin
Use the @@mcp attribute to control which models are exposed to AI assistants.
In your schema.zmodel:
plugin mcp {
provider = 'zenstack-mcp'
output = './zenstack'
}
model User {
id String @id @default(cuid())
email String @unique
// exposed to MCP by default
}
model AuditLog {
id String @id
// hide sensitive models from AI assistants
@@mcp(false)
}
model Post {
id String @id @default(cuid())
// cap `take` at 100 for list reads via the `execute` tool
@@mcp(limit: 100)
}Per-caller conditions
The expose argument also accepts a policy-style condition over auth(), evaluated per request against the authenticated caller — the same user your getClient receives (null for an anonymous caller under authMode: "optional"):
model Booking {
// visible to admins only — vanishes from `schema`, the `execute`
// model enum and tools/list for everyone else
@@mcp(auth().role == ADMIN)
}
model Trade {
// any signed-in caller, capped at 50 rows per list read
@@mcp(auth() != null, limit: 50)
}
model Doc {
// PUBLIC — visible to anonymous callers too (read-only), under authMode: "optional"
@@mcp(true)
}| Form | Authenticated caller | Anonymous caller (authMode: "optional") |
|------|----------------------|-------------------------------------------|
| (no @@mcp) | visible | hidden |
| @@mcp(false) | hidden | hidden |
| @@mcp(<expression>) | visible iff the expression is truthy | visible iff truthy with auth() = null (read-only) |
| @@mcp(true) | visible | visible, read-only — the explicit public opt-in |
| @@mcp(…, readOnly: true) | visible, read operations only | (same, anonymous is always read-only) |
| @@mcp(…, readOnly: <expression>) | read-only iff truthy for the caller | (same) |
Notes:
- Conditions may use
auth(), member access on it, literals,null, arrays,!,==,!=,<,<=,>,>=,&&,||andin. Anything that needs a row in scope (field references,this, collection predicates) is rejected atzen generatetime — a model's visibility cannot depend on a row. - Compare enum fields against the enum member (
auth().role == ADMIN), not a string:== "ADMIN"happens to work at runtime but the ZModel checker doesn't validate it against the enum. auth()requires an auth model in the schema (@@auth()or a model namedUser) — without one,zen generatefails resolving the condition.- The anonymous surface is always read-only (
executeoffers read operations only, and neithermenorprocedureis registered), and a schema with no public opt-in exposes no built-in tool anonymously. readOnly:strips the write operations of that model from theexecuteenum (statically, or per caller with an expression such asreadOnly: auth().role != ADMIN). It stacks with the server-levelreadOnlyoption — the most restrictive wins.- The built-in tools declare MCP
ToolAnnotations:schema/mearereadOnlyHint: true, andexecute/procedurereportreadOnlyHint/destructiveHintfrom the surface the current caller actually got — a fully read-only caller seesexecutebadged read-only by MCP clients. - Breaking change (0.3.0): an explicit
@@mcp(true)now means public — underauthMode: "optional"the model becomes readable by anonymous callers. If you used@@mcp(true)only to override the plugin'sexpose = falsedefault, replace it with@@mcp(auth() != null).
Custom procedures
Custom procedures (procedure / mutation procedure) are exposed through the
procedure tool and run on the enhanced client's $procs surface, so your
access policies and plugins still apply:
procedure getCartTotal(cartId: String): Int
mutation procedure checkout(cartId: String, coupon: String?): OrderEvery declared procedure is exposed by default. To hide one, set its
exposed flag to false in the generated procedures map of
./zenstack/mcp-config.ts:
export const mcpConfig: McpConfig = {
models: { /* ... */ },
procedures: {
getCartTotal: { exposed: true },
checkout: { exposed: false }, // hidden from the `procedure` tool
},
}Run zen generate to produce ./zenstack/mcp-config.ts, then pass it to the handler:
import { mcpConfig } from '~/zenstack/mcp-config'
app.route('/mcp', createHonoMcpHandler({ schema, mcpConfig, auth: ..., getClient: ... }))MCP Tools
The server exposes these tools to connected AI clients:
| Tool | Description |
|------|-------------|
| schema | Returns the exposed schema as concise ZModel/Prisma-style text — model blocks with their fields and any custom procedures — so the AI understands your data model with minimal token overhead. Pass model + operation to get the exact JSON Schema of the execute args for that operation, disclosed progressively: shared shapes are named $defs referenced by $ref, definitions that don't fit the response are listed in pendingDefinitions, and each can be fetched individually via component. Pass depth instead for a self-contained document bounded to that relation depth |
| execute | Runs a Prisma-compatible query through your policy-enforced client — access-control policies are validated automatically before execution |
| procedure | Invokes a custom ZenStack procedure ($procs) through your policy-enforced client. Only registered when the schema declares procedures |
| me | Returns the authenticated user for the current request |
The execute tool supports ZenStack's full CRUD operation set, sourced directly
from the ORM so it never drifts: findMany, findUnique, findFirst,
findUniqueOrThrow, findFirstOrThrow, create, createMany,
createManyAndReturn, update, updateMany, updateManyAndReturn, upsert,
delete, deleteMany, count, exists, aggregate and groupBy. Restrict
the set per model via mcpConfig or modelOperations (see below).
Arguments are validated with ZenStack's own query schemas
(createQuerySchemaFactory from @zenstackhq/orm) before reaching the client:
where/select/include/orderBy/cursor/data are all checked (strictly —
unknown keys are rejected), enum values and compound @@unique keys are
understood, and @validate attribute rules apply. Relation nesting is
unbounded by default (the schemas are truly recursive); bound it with
relationDepth, and cap list reads with maxTake/@@mcp(limit: N).
API Reference
createHonoMcpHandler(config) / createExpressMcpHandler(config)
| Option | Type | Description |
|--------|------|-------------|
| schema | Schema | Your ZenStack schema object |
| auth | McpAuthAdapter \| McpBuiltInAuthOptions | Auth adapter or built-in config |
| getClient | (user) => Promise<PrismaClient> | Returns a policy-enforced Prisma client for the authenticated user |
| mcpConfig | McpConfig (optional) | Generated config from the ZenStack plugin |
| authMode | "required" \| "optional" | Default "required": every request needs a Bearer. "optional" serves unauthenticated requests anonymously (getClient(undefined)); an invalid Bearer is still rejected. The anonymous surface is the read-only set of models whose @@mcp condition passes with auth() = null — nothing else |
| protectedTools | readonly string[] | Tools that must answer 401 + WWW-Authenticate even under authMode: "optional" — that 401 is what makes MCP clients offer to connect an account |
| readOnly | boolean \| (user) => boolean | Restricts execute to read operations and drops the procedure tool for the callers the predicate accepts (e.g. (user) => user?.role !== 'ADMIN'). A coarser gate on top of policies; anonymous callers are always read-only regardless |
| registerTools | (server, { user }) => void | Registers host-defined tools on the per-request server. user is the authenticated caller (undefined when anonymous) so hosts can vary the surface per caller — what a caller can't use never even appears in their tools/list |
betterAuthMcpAdapter(auth)
Wraps a better-auth instance as an McpAuthAdapter.
OAuth client registration only accepts https:// redirect URIs, plus loopback
http://localhost / http://127.0.0.1 / http://[::1] callbacks for local development.
Stateless access tokens are audience-bound to the better-auth baseURL (override
with the resource option) — a token minted by another deployment sharing the
same secret is rejected.
getRequestUser()
Returns the currently authenticated user from within a request handler.
import { getRequestUser } from 'zenstack-mcp'
const user = getRequestUser()createInMemoryTokenStore()
Creates a default in-memory token store. Replace with a persistent implementation for production.
Security
Read this before deploying — two points are easy to get wrong and both bypass data protection silently.
getClient must return a policy-enforced (enhanced) client
The execute and procedure tools run the operation they are given directly on whatever
getClient returns. All data-access authorization lives in your ZenStack access policies,
enforced by the enhanced client — not in this package. If getClient returns a raw ORM
client, every authenticated user gets unrestricted read/write access to your entire database
through execute, and your procedures run with their policies bypassed.
// ✅ Correct — policies are enforced
getClient: async (user) => db.$use(new PolicyPlugin()).$setAuth({ id: user.id })
// ❌ Wrong — NO access control: every authenticated caller can read/write everything
getClient: async (user) => dbWhen getClient is called outside an authenticated context the user is undefined, which
ZenStack treats as an anonymous caller — so your policies must also be correct for the
anonymous case (@@allow('read', true) etc.).
Model exposure is not an authorization boundary
@@mcp(false), @@mcp(<condition>), mcpConfig, include, exclude, modelOperations
and the procedures exposure map decide the surface the MCP tools advertise — with a
condition, per caller. They do not restrict what the database can return:
- A model hidden from MCP is still reachable through a relation
include/selectfrom an exposed model (subject to your policies). - A hidden procedure is unreachable through the
proceduretool, but the underlying$procsmethod still exists on the client — exposure config is not a substitute for policy checks inside the procedure itself. - These options are a visibility filter — per-caller with
@@mcp(<condition>)— not a data-authorization control.
Always enforce real protection with ZenStack access policies. Treat exposure config as "what the AI sees first", and policies as "what the AI is actually allowed to touch".
Other hardening options
| Option | Recommendation |
|--------|----------------|
| requireWhereForBulk: true | Enable in production — rejects deleteMany/updateMany with an empty where, so an LLM can't wipe a whole table. |
| maxTake / @@mcp(limit: N) | Cap take on list reads globally (maxTake) and per model (@@mcp(limit: N)); the lower of the two wins. Note the cap applies when a take is provided — pair it with policies that keep result sets bounded. |
| relationDepth | Maximum relation-nesting depth accepted in where/select/include/data (default unlimited — validation schemas are truly recursive). Set a finite depth to reject deeply nested args at validation time, as a guardrail against expensive queries; it also bounds the depth documented by the schema tool. |
| initialAccessToken | Set for the built-in OAuth server — otherwise /register is open to anyone. Registered clients also expire after clientTtl (default 24h) to prevent registry exhaustion. |
| allowedOrigins | Set when the server may be reached from a browser — rejects requests from any other Origin (DNS-rebinding protection). Native clients send no Origin and are unaffected. |
| jwtSecret / better-auth secret | Must be ≥ 32 characters (enforced). Use a high-entropy random value. |
| resource (audience binding) | Access tokens are bound to this server (RFC 8707): they carry an aud claim — the request origin by default, or the configured resource — and are rejected when presented to any other server, so a token issued for (or stolen from) another service cannot be replayed here. Token requests naming a foreign resource are rejected with invalid_target. Set resource explicitly when the server sits behind a proxy that rewrites Host or is reachable through several hostnames. better-auth stateful mode validates opaque sessions via better-auth and carries no aud claim. |
| transport | Keep the default "streamable-http" for serverless/multi-instance hosts. "sse"/"both" keeps sessions in per-instance memory and only works on a single instance. |
| better-auth stateless mode | Access tokens (default 1h) and refresh tokens are not individually revocable until they expire — revoking the underlying better-auth session is caught only at refresh time. Use short TTLs, stateful: true for immediate revocation (single-instance), or pass refreshTokenReuse to make refresh tokens one-time-use (rotation with stolen-token replay detection). |
License
MIT
