mcp-plan-gate-example
Reference implementation for adding subscription-based gating to MCP endpoints, covering auth, plan entitlements, usage metering, and multi-tenant scoping.
README
mcp-plan-gate-example
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.
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_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.
Quickstart
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: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
# 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
# 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):
{
"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 |
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.
Adapting this to your SaaS
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.
Testing
npm test
Covers:
- 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)
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.
Built by Praj Vaggu (UV PRAJ TECH INC). I wire MCP endpoints into existing SaaS auth/billing. GitHub @TechCodeCrafter.
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.
E2B
Using MCP to run code via e2b.