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:
- Auth -- resolving an MCP caller to an account via a Bearer API key.
- Entitlements -- filtering and enforcing which tools a plan can see and call.
- Metering -- logging every tool call and enforcing a monthly quota per plan.
- 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.
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.
Teams keep filing real issues and shipping real code for exactly this problem. A few recent precedents:
- PostHog shipped a
feature_entitlementgate for MCP tools, resolving an org's plan entitlements at request time and hiding gated tools from unentitled orgs — PostHog/posthog#68838. - ErabliereApi merged a
RequirePlanMiddleware-style gate on their/mcpendpoint, restricting the hosted MCP transport to subscription plans that include MCP access — ErabliereApi/ErabliereApi#389. - Canny sells MCP connector access as a feature on its paid plans, alongside other Autopilot AI capabilities — Canny 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.
Requires Node 20+.
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:3000npm 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).
# 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>"# 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.
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):
{
"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.
| Concern | Source file |
|---|---|
| Auth (Bearer API key -> user) | src/auth.ts |
Plan gating (filter tools/list, block tools/call) |
src/planGate.ts |
| Metering + quota enforcement | src/metering.ts |
| Multi-tenant scoping | src/tools/records.ts |
| Wiring it all together per-request | src/mcp/server.ts, src/routes/mcp.ts |
| Tool definitions | src/tools/definitions.ts |
| Billing (Stripe test mode, optional) | src/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)
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.
Start with 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
resolveApiKeyinsrc/auth.tswith a lookup against your existing session/API-key/JWT system. Everything downstream only needs a resolved user object with anidand aplan-- it does not care how you got there. - Entitlement map -> your plan/feature-flag source.
PLAN_ENTITLEMENTSinsrc/planGate.tsis 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_eventsinsrc/metering.tsis 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 inmetering.tsfor calls into that system;planGate.tsand 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(insrc/billing.ts) at the client's existing billing source of truth" so theirusers.plancolumn stays correct.
npm testCovers:
- plan gating filters
tools/listcorrectly for each plan (test/planGate.test.ts) - non-entitled
tools/callrequests 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)
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
MIT -- see LICENSE.
Built by Praj Vaggu (UV PRAJ TECH INC). I wire MCP endpoints into existing SaaS auth/billing. GitHub @TechCodeCrafter.