Skip to content

TechCodeCrafter/mcp-plan-gate-example

Repository files navigation

mcp-plan-gate-example

CI

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.
  • 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.
  • 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 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

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.


Built by Praj Vaggu (UV PRAJ TECH INC). I wire MCP endpoints into existing SaaS auth/billing. GitHub @TechCodeCrafter.

About

Reference implementation: gating a SaaS MCP endpoint by subscription plan — auth, entitlements, metering, multi-tenant scoping. TypeScript + official MCP SDK.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages