npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

zenstack-mcp

v0.2.6

Published

Turnkey MCP (Model Context Protocol) server for ZenStack v3 with OAuth 2.0 authentication

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-mcp

Quick 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 app

With 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, !, ==, !=, <, <=, >, >=, &&, || and in. Anything that needs a row in scope (field references, this, collection predicates) is rejected at zen generate time — 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 named User) — without one, zen generate fails resolving the condition.
  • The anonymous surface is always read-only (execute offers read operations only, and neither me nor procedure is registered), and a schema with no public opt-in exposes no built-in tool anonymously.
  • readOnly: strips the write operations of that model from the execute enum (statically, or per caller with an expression such as readOnly: auth().role != ADMIN). It stacks with the server-level readOnly option — the most restrictive wins.
  • The built-in tools declare MCP ToolAnnotations: schema/me are readOnlyHint: true, and execute/procedure report readOnlyHint/ destructiveHint from the surface the current caller actually got — a fully read-only caller sees execute badged read-only by MCP clients.
  • Breaking change (0.3.0): an explicit @@mcp(true) now means public — under authMode: "optional" the model becomes readable by anonymous callers. If you used @@mcp(true) only to override the plugin's expose = false default, 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?): Order

Every 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) => db

When 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/select from an exposed model (subject to your policies).
  • A hidden procedure is unreachable through the procedure tool, but the underlying $procs method 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