Skip to main content
Glama
TechCodeCrafter

mcp-plan-gate-example

README.md
# mcp-plan-gate-example

[![CI](https://github.com/TechCodeCrafter/mcp-plan-gate-example/actions/workflows/ci.yml/badge.svg)](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).