mcp-plan-gate-example
README.md
# mcp-plan-gate-example
[](https://github.com/TechCodeCrafter/mcp-plan-gate-example/actions/workflows/ci.yml)
**Reference implementation: gating a SaaS MCP endpoint by subscription plan — auth, entitlements, metering, and multi-tenant scoping.**
This is a small, runnable TypeScript SaaS with a single MCP (Model Context
Protocol) endpoint at `POST /mcp`. It demonstrates the four things that come
up in almost every "put MCP behind our paid plans" engagement:
1. **Auth** -- resolving an MCP caller to an account via a Bearer API key.
2. **Entitlements** -- filtering *and* enforcing which tools a plan can see and call.
3. **Metering** -- logging every tool call and enforcing a monthly quota per plan.
4. **Tenant scoping** -- making sure every tool only ever touches its caller's own data.
Nothing here is exotic. That is the point: this is meant to be the kind of
code you can drop into an existing SaaS's codebase in an afternoon, not a
framework.
## Architecture
```
MCP client / agent mcp-plan-gate-example
(Claude Desktop, Cursor, POST /mcp
custom LLM app, curl) ------------> +--------+ +-----------+ +------------------------+
| auth | --> | plan gate | --> | metering / quota gate |
+--------+ +-----------+ +------------------------+
Bearer API src/planGate.ts src/metering.ts
key -> user filters logs usage_events,
src/auth.ts tools/list, enforces monthly quota
| blocks tools/call |
| for non-entitled v
| tools +------------------+
+----------------------------> | tool handlers |
| ping / search / |
| get / export / |
| bulk_update |
+--------+---------+
|
v
+--------------------------+
| tenant-scoped SQLite |
| every query filtered by |
| user_id |
| src/tools/records.ts |
+--------------------------+
^
| webhook updates users.plan column
|
+-----------+------------+
| Stripe (test mode) |
| checkout.session.completed,
| customer.subscription.* |
| src/billing.ts |
+---------------------------+
```
The plain REST side of the SaaS (`POST /signup`, `GET /me`, `GET /usage`) sits
next to `/mcp` and shares the same `users` table -- MCP is just another
authenticated surface on an existing API, not a separate system.
## Why this exists
Teams keep filing real issues and shipping real code for exactly this
problem. A few recent precedents:
- PostHog shipped a `feature_entitlement` gate for MCP tools, resolving an
org's plan entitlements at request time and hiding gated tools from
unentitled orgs — [PostHog/posthog#68838](https://github.com/PostHog/posthog/pull/68838).
- ErabliereApi merged a `RequirePlanMiddleware`-style gate on their `/mcp`
endpoint, restricting the hosted MCP transport to subscription plans that
include MCP access — [ErabliereApi/ErabliereApi#389](https://github.com/ErabliereApi/ErabliereApi/pull/389).
- Canny sells MCP connector access as a feature on its paid plans, alongside
other Autopilot AI capabilities — [Canny pricing](https://canny.io/pricing).
If your SaaS is adding an MCP endpoint, this is not a hypothetical future
problem -- it is the first thing customers, security reviewers, and your own
billing team will ask about.
## Quickstart
Requires Node 20+.
```bash
npm install
npm run seed # creates data/app.db with 3 demo users (free/pro/business)
npm run dev # starts the server on http://localhost:3000
```
`npm run seed` prints three API keys, one per plan. Save them -- they are
not stored anywhere in plaintext and won't be printed again (a real signup
flow would show the key exactly once, the same way Stripe or GitHub do).
### Try the REST side
```bash
# Create a new (free-plan) account
curl -s -X POST http://localhost:3000/signup \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com"}'
# Check your account + entitlements
curl -s http://localhost:3000/me -H "Authorization: Bearer <api_key>"
# Check current usage against your monthly quota
curl -s http://localhost:3000/usage -H "Authorization: Bearer <api_key>"
```
### Try the MCP endpoint directly
```bash
# 1. initialize
curl -s -X POST http://localhost:3000/mcp \
-H "Authorization: Bearer <pro_api_key>" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}'
# 2. tools/list -- notice the tool list differs by plan
curl -s -X POST http://localhost:3000/mcp \
-H "Authorization: Bearer <pro_api_key>" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
# 3. tools/call -- a tool this plan is entitled to
curl -s -X POST http://localhost:3000/mcp \
-H "Authorization: Bearer <pro_api_key>" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_records","arguments":{"query":"a"}}}'
# 4. tools/call -- a tool this plan is NOT entitled to (try with a free-plan key)
curl -s -X POST http://localhost:3000/mcp \
-H "Authorization: Bearer <free_api_key>" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"search_records","arguments":{"query":"a"}}}'
```
The rejected call returns a normal JSON-RPC error with an upgrade message,
not a bare HTTP 403 -- MCP clients (and the LLMs driving them) can surface
that message directly to the end user.
### Connect it to Claude Desktop or Cursor
Most MCP clients support a `mcp-remote`-style bridge for HTTP servers, or
native Streamable HTTP config. Example config block (adjust to your
client's config file location):
```json
{
"mcpServers": {
"plan-gate-demo": {
"url": "http://localhost:3000/mcp",
"headers": {
"Authorization": "Bearer <api_key_from_seed_script>"
}
}
}
}
```
With a free-plan key, the client will only ever see `ping`. Swap in the
pro or business key (from `npm run seed`) and re-list tools to see the
difference live.
## Walkthrough of the four concerns
| Concern | Source file |
|---|---|
| Auth (Bearer API key -> user) | [`src/auth.ts`](./src/auth.ts) |
| Plan gating (filter `tools/list`, block `tools/call`) | [`src/planGate.ts`](./src/planGate.ts) |
| Metering + quota enforcement | [`src/metering.ts`](./src/metering.ts) |
| Multi-tenant scoping | [`src/tools/records.ts`](./src/tools/records.ts) |
| Wiring it all together per-request | [`src/mcp/server.ts`](./src/mcp/server.ts), [`src/routes/mcp.ts`](./src/routes/mcp.ts) |
| Tool definitions | [`src/tools/definitions.ts`](./src/tools/definitions.ts) |
| Billing (Stripe test mode, optional) | [`src/billing.ts`](./src/billing.ts), [`src/routes/billing.ts`](./src/routes/billing.ts) |
**Auth** (`src/auth.ts`): a Bearer API key resolves to a row in `users`.
The file has a comment block on exactly where full OAuth 2.1 (per the [MCP
authorization spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization))
would slot in later -- API keys are the pragmatic v1 almost every SaaS
ships first, because customers already think in terms of API keys from
the REST API.
**Plan gating** (`src/planGate.ts`): one declarative map,
`PLAN_ENTITLEMENTS`, says which tools each plan includes. Two functions
enforce it: `filterToolsForPlan` narrows what `tools/list` returns (so
non-entitled tools are invisible, not just blocked), and
`assertToolEntitlement` is a second, defense-in-depth check inside
`tools/call` (because clients cache lists, and models don't always respect
what they were told is unavailable).
**Metering** (`src/metering.ts`): every `tools/call` -- success or failure
-- is written to `usage_events`. `assertWithinQuota` checks the caller's
plan-specific monthly limit (free = 50, pro = 5,000, business = unlimited)
before the tool runs, and throws a normal MCP error when exceeded.
`GET /usage` exposes the same numbers to the caller directly.
**Tenant scoping** (`src/tools/records.ts`): every single query is filtered
by `user_id`, including inside `bulk_update`, where attempting to touch
another tenant's record ID quietly no-ops (`updated: false`) instead of
throwing -- so a caller probing for other tenants' IDs learns nothing.
## Adapting this to your SaaS
Start with [ADOPTING.md](./ADOPTING.md) — the ten questions to answer before
putting your own MCP endpoint behind a paid plan, each mapped to the file in
this repo that answers it.
This repo is meant to be gutted and rewired, not run as-is in production.
The seams are intentionally narrow:
- **API keys -> your auth.** Replace `resolveApiKey` in `src/auth.ts` with a
lookup against your existing session/API-key/JWT system. Everything
downstream only needs a resolved user object with an `id` and a `plan` --
it does not care how you got there.
- **Entitlement map -> your plan/feature-flag source.** `PLAN_ENTITLEMENTS`
in `src/planGate.ts` is deliberately just a plain object. Point it at
your existing feature-flag service, plan config table, or entitlements
API instead of hardcoding it -- the filtering and blocking logic around
it does not change.
- **Usage table -> your billing meter.** `usage_events` in `src/metering.ts`
is a stand-in for whatever usage-metering pipeline you already have (a
metrics warehouse, Stripe usage records for metered billing, an internal
rate-limiter). Swap the read/write calls in `metering.ts` for calls into
that system; `planGate.ts` and the tool handlers never need to know.
- **Stripe wiring is optional and replaceable.** In a real engagement, the
work is almost never "build billing from scratch" -- it's "point
`applyPlanFromStripeEvent` (in `src/billing.ts`) at the client's existing
billing source of truth" so their `users.plan` column stays correct.
## Testing
```bash
npm test
```
Covers:
- plan gating filters `tools/list` correctly for each plan (`test/planGate.test.ts`)
- non-entitled `tools/call` requests are rejected with an upgrade message (`test/planGate.test.ts`)
- monthly quota enforcement kicks in once a plan's limit is hit (`test/quota.test.ts`)
- tenant scoping: one account can never read or modify another account's records (`test/tenantScoping.test.ts`)
## Project layout
```
src/
db.ts SQLite connection + schema (users, records, usage_events)
apiKey.ts demo API key generation
auth.ts Bearer API key -> user resolution
planGate.ts entitlement map + tools/list filtering + tools/call blocking
metering.ts usage logging + monthly quota enforcement
billing.ts Stripe (test mode) plan-sync logic, fully optional
server.ts Express app entrypoint
mcp/server.ts per-request MCP Server construction (tools/list, tools/call handlers)
routes/
core.ts POST /signup, GET /me, GET /usage
mcp.ts POST /mcp (Streamable HTTP transport)
billing.ts POST /billing/checkout, POST /billing/webhook
tools/
definitions.ts tool metadata + handlers (ping, search_records, get_record, export_data, bulk_update)
records.ts tenant-scoped data access
scripts/
seed.ts creates 3 demo users (free/pro/business) + fake records
test/
testServer.ts test harness (real HTTP against an isolated SQLite file)
planGate.test.ts
quota.test.ts
tenantScoping.test.ts
```
## License
MIT -- see [LICENSE](./LICENSE).
---
Built by Praj Vaggu (UV PRAJ TECH INC). I wire MCP endpoints into existing
SaaS auth/billing. GitHub [@TechCodeCrafter](https://github.com/TechCodeCrafter).
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessUnresponsive