---
title: mcp-subs: a NestJS template for paid MCP servers
description: Technical spec for mcp-subs, a reusable NestJS template that turns any set of MCP tools into a subscription product, using the official TypeScript SDK v2, OAuth 2.1, SQLite, per-call usage tracking and Stripe billing.
date: 2026-09-27
lang: en-US
author: Julien Béranger
model: Claude Opus 5.5
conversation: https://claude.ai/chat/398898bd-a5cf-4928-97e9-3a8ccd612e36
source: https://julienberanger.com/mcp-subs-spec
---

# mcp-subs: a NestJS template for paid MCP servers

## Goal

`mcp-subs` is a template that handles everything a paid remote [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server needs apart from its tools. You clone it, write one class per tool, set some environment variables, and you have a server that people can connect to from any MCP client, such as [Claude](https://claude.ai/), and pay a monthly subscription to use.

What the template provides:

- **Protocol** handling through the official [TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) v2, running inside [NestJS](https://nestjs.com/) 12.
- **Auth**: [OAuth 2.1](https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/) according to the [MCP authorization spec](https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/authorization). The server acts as a resource server and works with any standards-compliant authorization server.
- **Data**: users, subscriptions and usage stored in a single [SQLite](https://www.sqlite.org/) file through [better-sqlite3](https://github.com/WiseLibs/better-sqlite3), with migrations applied automatically at startup.
- **Usage tracking**: a quota check and a usage row for every metered tool call.
- **Billing**: subscriptions through [Stripe](https://stripe.com/), using Checkout, the Customer Portal and webhooks. Users can pay from inside the chat through a short signed link, or from a pricing page before they ever connect.
- **Two starter tools**: `account`, which is built in and shows plan and usage, and `word_stats`, a placeholder paid tool that you replace.

What it deliberately leaves out is anything specific to one product. Rukh-style file selection, for example, becomes a tool you add, as described in [Adding a tool](#adding-a-tool).

## Status

The code in this spec builds and was smoke-tested end to end on Node 22 with better-sqlite3 13 (SQLite 3.53), a local stand-in authorization server that signs RS256 JWTs, and signed Stripe test webhooks. The following behaviors were confirmed:

- A request with no token gets a `401` with a `resource_metadata` challenge.
- The discovery document is served.
- A token issued for a different audience is rejected.
- `initialize`, `tools/list` and `tools/call` all work.
- A usage row is written for each metered call and counted in the current month.
- The database is created on first boot, with migrations applied and WAL mode on.
- A request with an unexpected `Host` header gets a `403`.
- A webhook with a bad signature gets a `400`.
- The pricing page is served at `/`.
- The `account` tool returns a signed link on your own domain (about 90 characters).
- A tampered or made-up link gets a `404`.
- Once the free quota is used up, the tool returns an error result containing the upgrade link.
- **Pay before connecting**: after `customer.created` and `customer.subscription.created` webhooks for `alice@example.com`, a user who connects with a verified `Alice@Example.com` in their token is linked automatically and gets `pro`.
- A matching email that the authorization server marks as unverified (`email_verified: false`) is not linked, and that user stays on `free`.

Three things were **not** tested: a real OAuth login flow with a real client, live Stripe Checkout, and the final redirect to Stripe from a valid link. The sandbox had no network access to Stripe, so a valid link got as far as calling Stripe and no further.

## How users pay

The card is always entered on Stripe's hosted Checkout page, in a normal browser tab. It is never typed into the chat, and it never reaches the AI provider or your server. That keeps you out of most PCI scope, and it works the same in Claude, ChatGPT or any other client that renders links.

There are two ways in.

**From the chat.** The `account` tool and the over-quota error both return a short link such as `https://mcp.example.com/b/<userId>.<exp>.<sig>`. When the user clicks it, the server checks the signature and expiry, then redirects to a Checkout session created on the spot (for free users) or to the Customer Portal (for paying users). This design has three advantages:

- The model only has to repeat a short URL on your domain, not a long Stripe URL it might mangle.
- The link can't go stale the way a Stripe session can, because a new session is created on every click.
- No Stripe API call happens during the tool call itself, so tool calls stay fast.

**From the pricing page, before connecting.** `GET /` shows the plans, a Subscribe button and the connector URL. Stripe Checkout collects the email. When the user later connects and signs in, the server matches the verified email in their access token to the Stripe customer and links the two automatically. It does this only when exactly one unclaimed customer has that email.

Either way, once Stripe's webhook arrives, the next tool call sees the new plan. There's no need to reconnect.

## Architecture

```mermaid
flowchart LR
  C[MCP client] -->|HTTPS| RP[Reverse proxy / TLS]
  RP --> N[NestJS app]
  C -. OAuth 2.1 + PKCE .-> AS[Authorization server]
  N -->|JWKS| AS
  N --> DB[(SQLite file)]
  S[Stripe] -->|webhooks| N
  N -->|Checkout / Portal redirects| S
  B[Browser] -->|/ and /b/:token| N
  B --> S
```

A request to `/mcp` goes through these stages in order:

```text
Nest middleware:  hostHeaderValidation → requireBearerAuth      (@modelcontextprotocol/express)
Controller:       McpController @All() → toNodeHandler           (@modelcontextprotocol/node)
SDK:              createMcpHandler → McpFactory.build(ctx)        (@modelcontextprotocol/server)
Per request:      new McpServer + every McpTool.register(server, user)
```

Four design decisions underlie this:

- **The server is only a resource server.** It verifies JWTs and publishes [Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728) (RFC 9728) so clients can find the authorization server. It never issues tokens itself.
- **It is stateless per request.** `createMcpHandler` serves the 2026-07-28 protocol and falls back to stateless serving for clients on the 2025 revisions. With no sessions to keep, restarts and extra instances need no special handling.
- **Billing is kept separate from auth.** Stripe webhooks write subscription state to the database, and tool calls read it from there. OAuth scopes never encode whether someone has paid.
- **Tools are Nest providers.** Each tool is an `@Injectable()` class that receives the resolved `User`, so it can inject any service it needs, whether a database, an LLM client or your own domain logic.

## Stack

| Concern | Choice |
| --- | --- |
| Runtime | [Node.js](https://nodejs.org/) 22 LTS (Nest 12 and the SDK both require 20 or later) |
| Framework | NestJS 12, which is ESM-only, on its [Express](https://expressjs.com/) 5 platform |
| MCP | [`@modelcontextprotocol/server`](https://www.npmjs.com/package/@modelcontextprotocol/server), [`/express`](https://www.npmjs.com/package/@modelcontextprotocol/express), [`/node`](https://www.npmjs.com/package/@modelcontextprotocol/node) |
| Validation | [Zod](https://zod.dev/) 4 |
| JWT verification | [`jose`](https://github.com/panva/jose) |
| Database | SQLite through [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) 13, in [WAL mode](https://www.sqlite.org/wal.html) |
| Billing | [`stripe`](https://github.com/stripe/stripe-node) |

```bash
npm i @nestjs/core @nestjs/common @nestjs/platform-express reflect-metadata rxjs \
      @modelcontextprotocol/server @modelcontextprotocol/express @modelcontextprotocol/node \
      express zod jose better-sqlite3 stripe
npm i -D typescript @types/express @types/node @types/better-sqlite3
```

`package.json` needs `"type": "module"`, plus scripts along these lines:

```json
{
  "type": "module",
  "scripts": {
    "build": "tsc -p .",
    "start": "node dist/main.js"
  }
}
```

```json
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "strictPropertyInitialization": false,
    "outDir": "dist",
    "rootDir": "src",
    "types": ["node"],
    "skipLibCheck": true
  },
  "include": ["src"]
}
```

Nest's dependency injection depends on `experimentalDecorators` and `emitDecoratorMetadata`. Because of that, any class that gets injected has to be imported as a value, never with `import type`.

## Project layout

```text
mcp-subs/
├── data/app.db                     # created on first boot (gitignored)
├── migrations/001_init.sql         # applied automatically at startup
└── src/
    ├── main.ts                     # bootstrap, discovery route
    ├── app.module.ts               # root module + /mcp middleware
    ├── config.ts                   # env parsing
    ├── database/database.module.ts # opens the DB, pragmas, migrations
    ├── auth/                       # AS discovery + token verifier
    ├── users/                      # sub → user row
    ├── billing/                    # plans, Checkout, Portal, webhook,
    │                               # pricing page (/), signed links (/b/:token)
    ├── usage/                      # quota + usage recording
    └── mcp/
        ├── mcp-tool.ts             # the McpTool interface
        ├── mcp.factory.ts          # builds an McpServer per request
        ├── mcp.controller.ts       # /mcp endpoint
        ├── mcp.module.ts           # ← register your tools here
        └── tools/
            ├── account.tool.ts     # built-in
            └── example.tool.ts     # replace me
```

## Configuration

```typescript
// src/config.ts
import { z } from 'zod';

const Env = z.object({
  PORT: z.coerce.number().default(3000),
  PUBLIC_URL: z.url(),                 // https://mcp.example.com
  DATABASE_PATH: z.string().default('./data/app.db'),
  AUTH_ISSUER: z.url(),                // your authorization server's issuer
  STRIPE_SECRET_KEY: z.string(),
  STRIPE_WEBHOOK_SECRET: z.string(),
  STRIPE_PRICE_PRO: z.string(),        // price_...
  PRO_PRICE_LABEL: z.string().default('€9 / month'), // shown on the pricing page
  LINK_SECRET: z.string().min(32),     // signs billing links: openssl rand -hex 32
});

export const env = Env.parse(process.env);
export const MCP_URL = new URL('/mcp', env.PUBLIC_URL);
```

| Variable | Example |
| --- | --- |
| `PUBLIC_URL` | `https://mcp.example.com` |
| `DATABASE_PATH` | `./data/app.db` (default) |
| `AUTH_ISSUER` | `https://auth.example.com` |
| `STRIPE_SECRET_KEY` | `sk_live_...` |
| `STRIPE_WEBHOOK_SECRET` | `whsec_...` |
| `STRIPE_PRICE_PRO` | `price_...` |
| `PRO_PRICE_LABEL` | `€9 / month` (display only, on the pricing page) |
| `LINK_SECRET` | 32+ random characters, e.g. `openssl rand -hex 32` |

## Database

### Why SQLite

The template's workload is small. Each request does one upsert, each tool call does two small reads, and each metered call does one insert. better-sqlite3 runs these in-process and synchronously, typically in microseconds. There is no database server to install, secure or connect to, so someone cloning the template can start with `npm run dev` and nothing else running.

The trade-off is that **you're limited to one machine.** The request handling is stateless, but separate instances can't share one SQLite file.

### Schema

The schema uses SQLite's conventions. Timestamps are Unix epoch seconds, which matches what Stripe already sends, so `current_period_end` is stored exactly as received. JSON is stored as text, and IDs are generated in code with `crypto.randomUUID()`.

Subscriptions are keyed by **Stripe customer**, not by user. That way, a subscription paid on the pricing page can be recorded before the user who owns it has ever connected. The `customers` table is a small mirror of Stripe customers and their emails, kept up to date by webhooks, and it's what makes linking by email possible.

```sql
-- migrations/001_init.sql
-- Timestamps are Unix epoch seconds (integer). JSON is stored as text.
create table users (
  id                 text primary key,              -- crypto.randomUUID()
  sub                text not null unique,          -- subject from the access token
  email              text,
  stripe_customer_id text unique,
  created_at         integer not null default (unixepoch())
);

-- Mirror of Stripe customers (customer.created / customer.updated webhooks).
-- Lets someone pay on the pricing page first and be linked by email when they connect.
create table customers (
  id         text primary key,                      -- cus_...
  email      text,
  updated_at integer not null default (unixepoch())
);
create index customers_email on customers (email collate nocase);

-- Keyed by Stripe customer, not by user, so a subscription can exist before its user does.
create table subscriptions (
  id                 text primary key,              -- sub_...
  customer_id        text not null,                 -- cus_...
  plan               text not null,                 -- 'free' | 'pro'
  status             text not null,                 -- active, trialing, past_due, canceled...
  current_period_end integer not null,
  updated_at         integer not null default (unixepoch())
);
create index subscriptions_customer on subscriptions (customer_id);

create table usage_events (
  id             integer primary key,
  user_id        text not null references users(id) on delete cascade,
  tool           text not null,
  status         text not null,                     -- 'ok' | 'error'
  duration_ms    integer not null,
  input_tokens   integer not null default 0,        -- if the tool calls an upstream LLM
  output_tokens  integer not null default 0,
  cost_usd       real not null default 0,           -- your cost, for margin tracking
  meta           text not null default '{}',        -- JSON
  created_at     integer not null default (unixepoch())
);
create index usage_user_time on usage_events (user_id, created_at);
```

### Connection and migrations

```typescript
// src/database/database.module.ts
import { Global, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
import Database from 'better-sqlite3';
import { mkdirSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { env } from '../config.js';

export const DB = Symbol('DB');
export type Db = Database.Database;

const MIGRATIONS_DIR = join(import.meta.dirname, '../../migrations');

function openDatabase(): Db {
  mkdirSync(dirname(env.DATABASE_PATH), { recursive: true });
  const db = new Database(env.DATABASE_PATH);
  db.pragma('journal_mode = WAL');   // readers don't block the writer
  db.pragma('synchronous = NORMAL'); // safe with WAL, much faster than FULL
  db.pragma('foreign_keys = ON');
  db.pragma('busy_timeout = 5000');
  migrate(db);
  return db;
}

/** Applies migrations/NNN_*.sql in order, tracked with PRAGMA user_version. */
function migrate(db: Db) {
  const current = db.pragma('user_version', { simple: true }) as number;
  const files = readdirSync(MIGRATIONS_DIR).filter((f) => /^\d+_.*\.sql$/.test(f)).sort();

  for (const file of files) {
    const version = parseInt(file, 10);
    if (version <= current) continue;
    db.transaction(() => {
      db.exec(readFileSync(join(MIGRATIONS_DIR, file), 'utf-8'));
      db.pragma(`user_version = ${version}`);
    })();
  }
}

@Global()
@Module({
  providers: [{ provide: DB, useFactory: openDatabase }],
  exports: [DB],
})
export class DatabaseModule implements OnApplicationShutdown {
  constructor(@Inject(DB) private readonly db: Db) {}

  onApplicationShutdown() {
    this.db.close();
  }
}
```

Four pragmas matter here:

- `journal_mode = WAL` lets tool calls keep reading while a Stripe webhook writes.
- `synchronous = NORMAL` is safe in WAL mode and much faster than the default.
- `foreign_keys = ON` enforces foreign keys, which SQLite doesn't do by default.
- `busy_timeout` waits briefly for a lock instead of failing immediately.

Migrations are plain numbered `.sql` files, tracked with `PRAGMA user_version` and applied in a transaction when the app starts, so there's no separate migrate step.

Queries use prepared statements created once in each service's constructor. They are synchronous, which is why `currentPlan()`, `callsThisMonth()` and `fromAuth()` return plain values rather than promises. If you add a tool that runs heavy queries, such as analytics over large tables, remember that a synchronous query blocks the event loop while it runs.

## OAuth 2.1

### Authorization server requirements

The template does not implement login. Whatever authorization server (AS) you pair it with has to support the following:

- the authorization code flow with PKCE;
- the `resource` parameter from [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707), and access tokens whose `aud` claim is `PUBLIC_URL/mcp`;
- [Client ID Metadata Documents](https://workos.com/blog/what-is-mcp-authorization), which the 2026-07-28 spec prefers (keep Dynamic Client Registration enabled too if you want older clients to connect);
- JWT access tokens and a JWKS endpoint;
- *(for paying before connecting)* the user's **verified** email included as an `email` claim in the access token. Most providers need a custom claim or an access-token template for this. Without it, the pricing-page path still takes payment, but the user won't be linked to it automatically.

The easiest route is a managed provider such as [WorkOS](https://workos.com/), [Auth0](https://auth0.com/) or [Descope](https://www.descope.com/). If you'd rather self-host, [Keycloak](https://www.keycloak.org/) is the usual choice, but check how the version you install handles the `resource` parameter and Client ID Metadata Documents first. Moving from one AS to another only means changing `AUTH_ISSUER`.

### Verifier

```typescript
// src/auth/auth.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import {
  OAuthError,
  OAuthErrorCode,
  type OAuthMetadata,
  type OAuthTokenVerifier,
} from '@modelcontextprotocol/server';
import { env, MCP_URL } from '../config.js';

export const AUTH_METADATA = Symbol('AUTH_METADATA');

// RFC 8414 first, OpenID Connect discovery as a fallback.
export async function discoverAuthServer(): Promise<OAuthMetadata> {
  const base = env.AUTH_ISSUER.replace(/\/$/, '');
  for (const path of ['/.well-known/oauth-authorization-server', '/.well-known/openid-configuration']) {
    const res = await fetch(base + path);
    if (res.ok) return (await res.json()) as OAuthMetadata;
  }
  throw new Error(`Could not discover authorization server metadata at ${base}`);
}

@Injectable()
export class AuthService {
  readonly verifier: OAuthTokenVerifier;

  constructor(@Inject(AUTH_METADATA) readonly metadata: OAuthMetadata) {
    const jwks = createRemoteJWKSet(new URL(String(metadata.jwks_uri)));

    this.verifier = {
      async verifyAccessToken(token) {
        try {
          const { payload } = await jwtVerify(token, jwks, {
            issuer: env.AUTH_ISSUER,
            audience: MCP_URL.href, // RFC 8707: reject tokens minted for another resource
          });
          if (!payload.sub || !payload.exp) throw new Error('missing sub/exp');
          return {
            token,
            clientId: String(payload.client_id ?? payload.azp ?? 'unknown'),
            scopes: typeof payload.scope === 'string' ? payload.scope.split(' ') : [],
            expiresAt: payload.exp,
            resource: MCP_URL,
            extra: {
              sub: payload.sub,
              // Only trust the email for account linking if the AS hasn't marked it unverified
              email: payload.email_verified === false ? undefined : payload.email,
            },
          };
        } catch (err) {
          throw new OAuthError(OAuthErrorCode.InvalidToken, (err as Error).message);
        }
      },
    };
  }
}
```

```typescript
// src/auth/auth.module.ts
import { Global, Module } from '@nestjs/common';
import { AUTH_METADATA, AuthService, discoverAuthServer } from './auth.service.js';

@Global()
@Module({
  providers: [
    // Async factory: resolved during NestFactory.create(), before middleware is configured.
    { provide: AUTH_METADATA, useFactory: discoverAuthServer },
    AuthService,
  ],
  exports: [AuthService],
})
export class AuthModule {}
```

Discovery runs in an **async provider factory** rather than in `onModuleInit`, and the order matters. Nest resolves async factories during `NestFactory.create()`, while it calls `configure()` for middleware *before* `onModuleInit` hooks run. If discovery happened in `onModuleInit`, the bearer middleware would be set up with a verifier that doesn't exist yet.

The `audience` check means a token that the same AS issued for some other API is rejected. It was tested with a wrong-audience token, which got a `401`.

### Users

```typescript
// src/users/users.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { AuthInfo } from '@modelcontextprotocol/server';
import { DB, type Db } from '../database/database.module.js';

export type User = { id: string; sub: string; email: string | null; stripe_customer_id: string | null };

@Injectable()
export class UsersService {
  private readonly upsert;
  private readonly setCustomer;
  private readonly unclaimedCustomers;
  private readonly getById;

  constructor(@Inject(DB) db: Db) {
    this.upsert = db.prepare<[string, string, string | null], User>(`
      insert into users (id, sub, email) values (?, ?, ?)
      on conflict (sub) do update set email = coalesce(excluded.email, users.email)
      returning id, sub, email, stripe_customer_id`);
    this.setCustomer = db.prepare<[string, string]>(
      `update users set stripe_customer_id = ? where id = ?`);
    // Stripe customers with this email that no user has claimed yet
    this.unclaimedCustomers = db.prepare<[string], { id: string }>(`
      select c.id from customers c
      where c.email = ? collate nocase
        and not exists (select 1 from users u where u.stripe_customer_id = c.id)`);
    this.getById = db.prepare<[string], User>(
      `select id, sub, email, stripe_customer_id from users where id = ?`);
  }

  byId(id: string): User | undefined {
    return this.getById.get(id);
  }

  fromAuth(auth: AuthInfo): User {
    const sub = String(auth.extra?.sub);
    const email = (auth.extra?.email as string | undefined) ?? null;
    const user = this.upsert.get(randomUUID(), sub, email)!;

    // Paid on the pricing page before connecting? Link by email — only when unambiguous.
    if (!user.stripe_customer_id && user.email) {
      const matches = this.unclaimedCustomers.all(user.email);
      if (matches.length === 1) {
        this.setStripeCustomer(user.id, matches[0]!.id);
        user.stripe_customer_id = matches[0]!.id;
      }
    }
    return user;
  }

  setStripeCustomer(userId: string, customerId: string) {
    this.setCustomer.run(customerId, userId);
  }
}
```

Email linking is deliberately conservative:

- The email only counts if the AS hasn't marked it unverified. The verifier drops it when `email_verified` is `false`.
- The comparison ignores case.
- A customer is linked only if it's unclaimed and it's the only unclaimed customer with that email.

In any other case, the user just gets a fresh customer the first time they use a link from the chat. If a pre-paying user can't be matched, for example because they signed in with a different email, it's a support case: set `users.stripe_customer_id` by hand.

## Stripe billing

```typescript
// src/billing/billing.service.ts
import { Inject, Injectable } from '@nestjs/common';
import Stripe from 'stripe';
import { env } from '../config.js';
import { DB, type Db } from '../database/database.module.js';
import { UsersService, type User } from '../users/users.service.js';

export type Plan = 'free' | 'pro';
export const PLAN_LIMITS: Record<Plan, { callsPerMonth: number }> = {
  free: { callsPerMonth: 50 },
  pro: { callsPerMonth: 5_000 },
};

@Injectable()
export class BillingService {
  readonly stripe = new Stripe(env.STRIPE_SECRET_KEY);

  private readonly activePlan;
  private readonly upsertSub;
  private readonly upsertCustomer;

  constructor(
    @Inject(DB) db: Db,
    private readonly users: UsersService,
  ) {
    this.activePlan = db.prepare<[string], { plan: Plan }>(`
      select s.plan from subscriptions s
      join users u on u.stripe_customer_id = s.customer_id
      where u.id = ?
        and s.status in ('active', 'trialing')
        and s.current_period_end > unixepoch()
      order by s.current_period_end desc
      limit 1`);
    this.upsertSub = db.prepare<[string, string, Plan, string, number]>(`
      insert into subscriptions (id, customer_id, plan, status, current_period_end)
      values (?, ?, ?, ?, ?)
      on conflict (id) do update set
        plan = excluded.plan,
        status = excluded.status,
        current_period_end = excluded.current_period_end,
        updated_at = unixepoch()`);
    this.upsertCustomer = db.prepare<[string, string | null]>(`
      insert into customers (id, email) values (?, ?)
      on conflict (id) do update set email = excluded.email, updated_at = unixepoch()`);
  }

  currentPlan(userId: string): Plan {
    return this.activePlan.get(userId)?.plan ?? 'free';
  }

  /** Checkout for a known user (from a signed link in the chat). */
  async checkoutForUser(user: User): Promise<string> {
    return this.createCheckout({
      customer: await this.ensureCustomer(user),
      client_reference_id: user.id,
    });
  }

  /** Checkout for an anonymous visitor (pricing page). Stripe collects the email. */
  async checkoutForVisitor(): Promise<string> {
    return this.createCheckout({});
  }

  async portalUrl(user: User): Promise<string> {
    const session = await this.stripe.billingPortal.sessions.create({
      customer: await this.ensureCustomer(user),
      return_url: env.PUBLIC_URL,
    });
    return session.url;
  }

  /** customer.created / customer.updated */
  syncCustomer(customer: Stripe.Customer) {
    this.upsertCustomer.run(customer.id, customer.email ?? null);
  }

  /** customer.subscription.created / updated / deleted */
  syncSubscription(sub: Stripe.Subscription) {
    const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
    const item = sub.items.data[0]!;
    const plan: Plan = item.price.id === env.STRIPE_PRICE_PRO ? 'pro' : 'free';
    // Stripe already uses Unix seconds, like our schema
    this.upsertSub.run(sub.id, customerId, plan, sub.status, item.current_period_end);
  }

  private async createCheckout(extra: Partial<Stripe.Checkout.SessionCreateParams>): Promise<string> {
    const session = await this.stripe.checkout.sessions.create({
      mode: 'subscription',
      line_items: [{ price: env.STRIPE_PRICE_PRO, quantity: 1 }],
      success_url: `${env.PUBLIC_URL}/billing/success`,
      cancel_url: `${env.PUBLIC_URL}/`,
      ...extra,
    });
    return session.url!;
  }

  private async ensureCustomer(user: User): Promise<string> {
    if (user.stripe_customer_id) return user.stripe_customer_id;
    const customer = await this.stripe.customers.create({
      email: user.email ?? undefined,
      metadata: { user_id: user.id },
    });
    this.users.setStripeCustomer(user.id, customer.id);
    return customer.id;
  }
}
```

```typescript
// src/billing/links.service.ts
import { Injectable } from '@nestjs/common';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { env } from '../config.js';

const TTL_SECONDS = 24 * 60 * 60;

/**
 * Short, signed billing links: PUBLIC_URL/b/<userId>.<exp>.<sig>
 * The model only has to repeat a short URL on your own domain; a fresh Stripe
 * session is created when the link is clicked, so it never goes stale.
 */
@Injectable()
export class LinksService {
  billingUrl(userId: string): string {
    const exp = Math.floor(Date.now() / 1000) + TTL_SECONDS;
    const payload = `${userId}.${exp}`;
    return `${env.PUBLIC_URL}/b/${payload}.${this.sign(payload)}`;
  }

  /** Returns the userId, or null if the token is forged or expired. */
  verify(token: string): string | null {
    const i = token.lastIndexOf('.');
    if (i < 0) return null;
    const payload = token.slice(0, i);
    const given = Buffer.from(token.slice(i + 1));
    const expected = Buffer.from(this.sign(payload));
    if (given.length !== expected.length || !timingSafeEqual(given, expected)) return null;

    const [userId, exp] = payload.split('.');
    if (!userId || Number(exp) < Date.now() / 1000) return null;
    return userId;
  }

  private sign(payload: string): string {
    return createHmac('sha256', env.LINK_SECRET).update(payload).digest('base64url').slice(0, 22);
  }
}
```

A link is a bearer credential for 24 hours. Anyone who has it can open that user's Customer Portal, where they could update the card or cancel the subscription. Twenty-four hours is short enough to limit that risk, and every call to `account` mints a fresh link. The signature is cut to 22 characters (132 bits) to keep the URL short.

```typescript
// src/billing/pages.controller.ts
import { Controller, Get, Header, NotFoundException, Param, Post, Res } from '@nestjs/common';
import type { Response } from 'express';
import { env, MCP_URL } from '../config.js';
import { UsersService } from '../users/users.service.js';
import { BillingService, PLAN_LIMITS } from './billing.service.js';
import { LinksService } from './links.service.js';

@Controller()
export class PagesController {
  constructor(
    private readonly billing: BillingService,
    private readonly links: LinksService,
    private readonly users: UsersService,
  ) {}

  /** Pricing page: for people who want to subscribe before connecting. */
  @Get()
  @Header('content-type', 'text/html; charset=utf-8')
  pricing() {
    return page('mcp-subs', `
      <h1>mcp-subs</h1>
      <p class="lead">Connect this server to Claude, ChatGPT or any MCP client.</p>
      <div class="plans">
        <section>
          <h2>Free</h2>
          <p class="price">€0</p>
          <p>${PLAN_LIMITS.free.callsPerMonth} tool calls / month</p>
        </section>
        <section>
          <h2>Pro</h2>
          <p class="price">${esc(env.PRO_PRICE_LABEL)}</p>
          <p>${PLAN_LIMITS.pro.callsPerMonth.toLocaleString('en')} tool calls / month</p>
          <form method="post" action="/subscribe"><button>Subscribe</button></form>
        </section>
      </div>
      <h2>Connect</h2>
      <p>Add a custom connector with this URL, then sign in:</p>
      <pre>${esc(MCP_URL.href)}</pre>
      <p class="note">Already subscribed here? Sign in with the same email you used at checkout
      and your plan is picked up automatically.</p>`);
  }

  /** POST so link previews and crawlers don't create Checkout sessions. */
  @Post('subscribe')
  async subscribe(@Res() res: Response) {
    res.redirect(303, await this.billing.checkoutForVisitor());
  }

  /** Signed link from the chat → a fresh Checkout (free) or Portal (paid) session. */
  @Get('b/:token')
  async billingLink(@Param('token') token: string, @Res() res: Response) {
    const userId = this.links.verify(token);
    const user = userId ? this.users.byId(userId) : undefined;
    if (!user) throw new NotFoundException('This link has expired. Ask for a new one with the account tool.');

    const url = this.billing.currentPlan(user.id) === 'free'
      ? await this.billing.checkoutForUser(user)
      : await this.billing.portalUrl(user);
    res.redirect(303, url);
  }

  @Get('billing/success')
  @Header('content-type', 'text/html; charset=utf-8')
  success() {
    return page('Subscribed', `
      <h1>You're on Pro</h1>
      <p>Your plan is active. Go back to your chat — no need to reconnect.</p>`);
  }
}

function esc(s: string) {
  return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
}

function page(title: string, body: string) {
  return `<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(title)}</title>
<style>
  :root { color-scheme: light dark; --fg: #1a1a1a; --bg: #fafafa; --card: #fff; --line: #ddd; --accent: #4f46e5; }
  @media (prefers-color-scheme: dark) { :root { --fg: #eee; --bg: #111; --card: #1b1b1b; --line: #333; --accent: #818cf8; } }
  body { font: 16px/1.5 system-ui, sans-serif; color: var(--fg); background: var(--bg); max-width: 40rem; margin: 3rem auto; padding: 0 1rem; }
  .lead { font-size: 1.15rem; }
  .plans { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 1rem; margin: 2rem 0; }
  section { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
  h2 { margin: 0 0 .25rem; } .price { font-size: 1.6rem; font-weight: 700; margin: 0; }
  button { font: inherit; background: var(--accent); color: #fff; border: 0; border-radius: 8px; padding: .6rem 1.2rem; cursor: pointer; }
  pre { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: .75rem; overflow-x: auto; }
  .note { opacity: .75; font-size: .9rem; }
</style></head>
<body>${body}</body></html>`;
}
```

Subscribing from the pricing page uses a `POST` form rather than a `GET` link, so that link previews and crawlers don't create Checkout sessions.

```typescript
// src/billing/billing.controller.ts
import { BadRequestException, Controller, Headers, HttpCode, Post, Req, type RawBodyRequest } from '@nestjs/common';
import type { Request } from 'express';
import { env } from '../config.js';
import { BillingService } from './billing.service.js';

@Controller()
export class BillingController {
  constructor(private readonly billing: BillingService) {}

  // Needs NestFactory.create(AppModule, { rawBody: true }) for req.rawBody
  @Post('stripe/webhook')
  @HttpCode(200)
  webhook(@Req() req: RawBodyRequest<Request>, @Headers('stripe-signature') signature: string) {
    let event;
    try {
      event = this.billing.stripe.webhooks.constructEvent(req.rawBody!, signature, env.STRIPE_WEBHOOK_SECRET);
    } catch {
      throw new BadRequestException('Invalid signature');
    }

    switch (event.type) {
      case 'customer.created':
      case 'customer.updated':
        this.billing.syncCustomer(event.data.object);
        break;
      case 'customer.subscription.created':
      case 'customer.subscription.updated':
      case 'customer.subscription.deleted':
        this.billing.syncSubscription(event.data.object);
        break;
    }
    return { received: true };
  }
}
```

Creating the app with `NestFactory.create(AppModule, { rawBody: true })` makes `req.rawBody` available on every route while Nest's usual JSON parsing continues to work. Stripe needs the raw body to verify its signature, so this is the only webhook-specific setup.

In the Stripe dashboard, point a webhook at `PUBLIC_URL/stripe/webhook` for the events `customer.created`, `customer.updated`, `customer.subscription.created`, `customer.subscription.updated` and `customer.subscription.deleted`, and turn on the [Customer Portal](https://docs.stripe.com/customer-management). For local testing, forward events with the [Stripe CLI](https://docs.stripe.com/stripe-cli):

```bash
stripe listen --forward-to localhost:3000/stripe/webhook
```

## Usage tracking

```typescript
// src/usage/usage.service.ts
import { Inject, Injectable } from '@nestjs/common';
import type { CallToolResult } from '@modelcontextprotocol/server';
import { DB, type Db } from '../database/database.module.js';
import { BillingService, PLAN_LIMITS } from '../billing/billing.service.js';
import { LinksService } from '../billing/links.service.js';
import type { User } from '../users/users.service.js';

/** Optional: what the call cost you (e.g. tokens spent on an upstream LLM). */
export type Cost = { inputTokens?: number; outputTokens?: number; usd?: number };

export type MeteredRun = () => Promise<{ result: CallToolResult; cost?: Cost; meta?: Record<string, unknown> }>;

@Injectable()
export class UsageService {
  private readonly countMonth;
  private readonly insert;

  constructor(
    @Inject(DB) db: Db,
    private readonly billing: BillingService,
    private readonly links: LinksService,
  ) {
    this.countMonth = db.prepare<[string], { n: number }>(`
      select count(*) as n from usage_events
      where user_id = ?
        and status = 'ok'
        and created_at >= unixepoch('now', 'start of month')`);
    this.insert = db.prepare<[string, string, string, number, number, number, number, string]>(`
      insert into usage_events (user_id, tool, status, duration_ms, input_tokens, output_tokens, cost_usd, meta)
      values (?, ?, ?, ?, ?, ?, ?, ?)`);
  }

  callsThisMonth(userId: string): number {
    return this.countMonth.get(userId)!.n;
  }

  /**
   * Quota check → run the tool → record one usage row.
   * Only tools that opt in are metered; initialize, tools/list etc. never reach this.
   */
  async metered(user: User, tool: string, run: MeteredRun): Promise<CallToolResult> {
    const plan = this.billing.currentPlan(user.id);
    const used = this.callsThisMonth(user.id);
    const limit = PLAN_LIMITS[plan].callsPerMonth;

    if (used >= limit) {
      return {
        isError: true,
        content: [{
          type: 'text',
          text: `Monthly limit reached (${used}/${limit} on the ${plan} plan). ` +
                `Upgrade here: ${this.links.billingUrl(user.id)}`,
        }],
      };
    }

    const started = Date.now();
    try {
      const { result, cost, meta } = await run();
      this.record(user.id, tool, result.isError ? 'error' : 'ok', Date.now() - started, cost, meta);
      return result;
    } catch (err) {
      this.record(user.id, tool, 'error', Date.now() - started);
      throw err;
    }
  }

  private record(
    userId: string, tool: string, status: 'ok' | 'error', durationMs: number,
    cost: Cost = {}, meta: Record<string, unknown> = {},
  ) {
    this.insert.run(userId, tool, status, durationMs,
      cost.inputTokens ?? 0, cost.outputTokens ?? 0, cost.usd ?? 0, JSON.stringify(meta));
  }
}
```

A tool opts into metering by wrapping its handler in `usage.metered()`. Protocol traffic such as `initialize` and `tools/list` never goes through it. When a user is over quota, the tool returns an error *result* that includes their signed upgrade link, so the model can pass it on to the user in plain language.

If you later want to charge for calls beyond the quota instead of blocking them, add a metered price and mirror each usage row to [Stripe Billing Meters](https://docs.stripe.com/billing/subscriptions/usage-based):

```typescript
await this.billing.stripe.billing.meterEvents.create({
  event_name: 'mcp_tool_call',
  identifier: String(usageEventId),       // one meter event per usage row
  payload: { stripe_customer_id: customerId, value: '1' },
});
```

## MCP layer

### The tool contract

```typescript
// src/mcp/mcp-tool.ts
import type { McpServer } from '@modelcontextprotocol/server';
import type { User } from '../users/users.service.js';

/** Implement this in an @Injectable() class and add it to TOOLS in mcp.module.ts. */
export interface McpTool {
  register(server: McpServer, user: User): void;
}

export const MCP_TOOLS = Symbol('MCP_TOOLS');
```

### Factory and controller

```typescript
// src/mcp/mcp.factory.ts
import { Inject, Injectable } from '@nestjs/common';
import { McpServer, type McpRequestContext } from '@modelcontextprotocol/server';
import { UsersService } from '../users/users.service.js';
import { MCP_TOOLS, type McpTool } from './mcp-tool.js';

@Injectable()
export class McpFactory {
  constructor(
    private readonly users: UsersService,
    @Inject(MCP_TOOLS) private readonly tools: McpTool[],
  ) {}

  /** Called once per HTTP request by createMcpHandler; authInfo is already verified. */
  async build({ authInfo }: McpRequestContext): Promise<McpServer> {
    if (!authInfo) throw new Error('Unauthenticated request reached the MCP handler');
    const user = this.users.fromAuth(authInfo);

    const server = new McpServer({ name: 'mcp-subs', version: '1.0.0' });
    for (const tool of this.tools) tool.register(server, user);
    return server;
  }
}
```

```typescript
// src/mcp/mcp.controller.ts
import { All, Controller, Req, Res, type OnApplicationShutdown } from '@nestjs/common';
import type { Request, Response } from 'express';
import { createMcpHandler, type McpHttpHandler } from '@modelcontextprotocol/server';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { McpFactory } from './mcp.factory.js';

@Controller('mcp')
export class McpController implements OnApplicationShutdown {
  private readonly handler: McpHttpHandler;
  private readonly serve: ReturnType<typeof toNodeHandler>;

  constructor(factory: McpFactory) {
    this.handler = createMcpHandler((ctx) => factory.build(ctx));
    this.serve = toNodeHandler(this.handler, { onerror: (e) => console.error(e) });
  }

  // Auth and Host validation run as middleware before this (see AppModule).
  @All()
  handle(@Req() req: Request, @Res() res: Response) {
    return this.serve(req, res, req.body);
  }

  onApplicationShutdown() {
    return this.handler.close();
  }
}
```

### Starter tools

```typescript
// src/mcp/tools/account.tool.ts
import { Injectable } from '@nestjs/common';
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/server';
import { BillingService, PLAN_LIMITS } from '../../billing/billing.service.js';
import { LinksService } from '../../billing/links.service.js';
import { UsageService } from '../../usage/usage.service.js';
import type { User } from '../../users/users.service.js';
import type { McpTool } from '../mcp-tool.js';

/** Built-in, never metered: lets users see their plan and reach Checkout or the Portal. */
@Injectable()
export class AccountTool implements McpTool {
  constructor(
    private readonly billing: BillingService,
    private readonly usage: UsageService,
    private readonly links: LinksService,
  ) {}

  register(server: McpServer, user: User) {
    server.registerTool(
      'account',
      {
        title: 'Account & billing',
        description: 'Shows the current plan, usage this month, and a link to upgrade or manage the subscription.',
        inputSchema: z.object({}),
        annotations: { readOnlyHint: true },
      },
      async () => {
        const plan = this.billing.currentPlan(user.id);
        const used = this.usage.callsThisMonth(user.id);
        const link = `${plan === 'free' ? 'Upgrade' : 'Manage subscription'}: ${this.links.billingUrl(user.id)}`;
        return {
          content: [{
            type: 'text',
            text: `Plan: ${plan}\nUsage this month: ${used}/${PLAN_LIMITS[plan].callsPerMonth}\n${link}`,
          }],
        };
      },
    );
  }
}
```

```typescript
// src/mcp/tools/example.tool.ts
import { Injectable } from '@nestjs/common';
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/server';
import { UsageService } from '../../usage/usage.service.js';
import type { User } from '../../users/users.service.js';
import type { McpTool } from '../mcp-tool.js';

/** Placeholder paid tool — replace with your product. */
@Injectable()
export class ExampleTool implements McpTool {
  constructor(private readonly usage: UsageService) {}

  register(server: McpServer, user: User) {
    server.registerTool(
      'word_stats',
      {
        title: 'Word statistics',
        description: 'Counts words, sentences and characters in a text.',
        inputSchema: z.object({ text: z.string().max(100_000) }),
        annotations: { readOnlyHint: true },
      },
      async ({ text }) =>
        this.usage.metered(user, 'word_stats', async () => {
          const words = text.trim() ? text.trim().split(/\s+/).length : 0;
          const sentences = (text.match(/[.!?]+(\s|$)/g) ?? []).length;
          return {
            result: {
              content: [{ type: 'text', text: JSON.stringify({ words, sentences, characters: text.length }) }],
            },
            meta: { characters: text.length },
          };
        }),
    );
  }
}
```

```typescript
// src/mcp/mcp.module.ts
import { Module } from '@nestjs/common';
import { BillingModule } from '../billing/billing.module.js';
import { UsageModule } from '../usage/usage.module.js';
import { UsersModule } from '../users/users.module.js';
import { McpController } from './mcp.controller.js';
import { McpFactory } from './mcp.factory.js';
import { MCP_TOOLS } from './mcp-tool.js';
import { AccountTool } from './tools/account.tool.js';
import { ExampleTool } from './tools/example.tool.js';

// Add your tools here.
const TOOLS = [AccountTool, ExampleTool];

@Module({
  imports: [UsersModule, BillingModule, UsageModule],
  controllers: [McpController],
  providers: [
    ...TOOLS,
    { provide: MCP_TOOLS, useFactory: (...tools) => tools, inject: TOOLS },
    McpFactory,
  ],
})
export class McpModule {}
```

## App wiring

```typescript
// src/app.module.ts
import { Module, type MiddlewareConsumer, type NestModule } from '@nestjs/common';
import {
  getOAuthProtectedResourceMetadataUrl,
  hostHeaderValidation,
  requireBearerAuth,
} from '@modelcontextprotocol/express';
import { env, MCP_URL } from './config.js';
import { AuthModule } from './auth/auth.module.js';
import { AuthService } from './auth/auth.service.js';
import { BillingModule } from './billing/billing.module.js';
import { DatabaseModule } from './database/database.module.js';
import { McpModule } from './mcp/mcp.module.js';

@Module({
  imports: [DatabaseModule, AuthModule, BillingModule, McpModule],
})
export class AppModule implements NestModule {
  constructor(private readonly auth: AuthService) {}

  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(
        hostHeaderValidation([new URL(env.PUBLIC_URL).hostname]), // DNS-rebinding guard
        requireBearerAuth({
          verifier: this.auth.verifier,
          resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(MCP_URL),
        }),
      )
      .forRoutes('mcp');
  }
}
```

```typescript
// src/main.ts
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { mcpAuthMetadataRouter } from '@modelcontextprotocol/express';
import { env, MCP_URL } from './config.js';
import { AppModule } from './app.module.js';
import { AuthService } from './auth/auth.service.js';

const app = await NestFactory.create<NestExpressApplication>(AppModule, {
  rawBody: true, // Stripe webhook signature verification
});
app.set('trust proxy', 1);
app.enableShutdownHooks();

// /.well-known/oauth-protected-resource/mcp (RFC 9728) + AS metadata passthrough
app.use(mcpAuthMetadataRouter({
  oauthMetadata: app.get(AuthService).metadata,
  resourceServerUrl: MCP_URL,
  resourceName: 'mcp-subs',
  scopesSupported: ['mcp:tools'],
}));

await app.listen(env.PORT, '127.0.0.1');
```

The Host-header check stands in for the DNS-rebinding protection that `createMcpExpressApp()` would give you in a plain Express app, which Nest doesn't use. The discovery router is mounted with `app.use()` because it has to answer paths under `/.well-known/…` that no controller owns.

## Adding a tool

There are three steps:

1. Create `src/mcp/tools/my.tool.ts` as an `@Injectable()` class that implements `McpTool`, and inject whatever services it needs.
2. Wrap the handler in `this.usage.metered(user, 'my_tool', …)` if the tool should count against the quota. If it calls a paid upstream API, return a `cost` as well, so that `usage_events` records your margin.
3. Add the class to `TOOLS` in `mcp.module.ts`, and add its module to `imports` if its dependencies live elsewhere.

### Example: Rukh-style file selection

To add the two-step selection from [Rukh](https://github.com/w3hc/rukh), import Rukh's `RagModule` (or a copy of it) into `McpModule` and write a `GetContextTool` that:

- calls `ragService.selectRelevantFiles(context, question)`;
- then calls `ragService.buildContextWithSelectedFiles(...)`;
- returns the text, with `selectionCost` mapped to `cost` and `selectedFiles` passed in `meta`.

The tool returns documents, not an answer. The client's model writes the answer, so the only model cost you pay per call is the cheap selection step.

## Deployment

Any host that runs Node 22 and has a persistent disk will do. The app listens on `127.0.0.1:PORT` and expects a reverse proxy in front of it to handle TLS. For a VPS, [Caddy](https://caddyserver.com/) is the simplest option:

```caddyfile
mcp.example.com {
    reverse_proxy 127.0.0.1:3000 {
        flush_interval -1   # don't buffer SSE responses
    }
}
```

Run the app under [systemd](https://systemd.io/), Docker or a PaaS, whichever you prefer. The production checklist is:

- keep secrets out of the repository;
- put `DATABASE_PATH` on persistent storage (a real disk path on a VPS, a mounted volume in Docker) that the service user can write to, since WAL mode also creates `-wal` and `-shm` files next to the database;
- back it up continuously with [Litestream](https://litestream.io/) to S3-compatible storage, or at least take nightly snapshots with `sqlite3 data/app.db ".backup /backups/app-$(date +%F).db"`. Never copy the file with plain `cp` while the app is running;
- run a single instance.

## Testing checklist

1. `curl -i -X POST $PUBLIC_URL/mcp` should return `401`, with `resource_metadata=` in the `WWW-Authenticate` header.
2. `curl $PUBLIC_URL/.well-known/oauth-protected-resource/mcp` should list your AS under `authorization_servers`.
3. Connect with the [MCP Inspector](https://github.com/modelcontextprotocol/inspector), complete the login, and call `account`.
4. Call `word_stats` more times than the free limit allows (lower `PLAN_LIMITS.free` to make this quick). The error should include a `/b/…` link.
5. Open the link, pay with test card `4242 4242 4242 4242`, and check that `account` then reports `pro`.
6. Open the new `account` link and check that it lands on the Customer Portal. Cancel there, and check that the plan returns to `free` once the period ends.
7. **Pay before connecting**: subscribe from `/` with a fresh email, then connect a new account that signs in with the same email. `account` should report `pro` straight away.
8. Add the server in Claude and in ChatGPT as a custom connector, and test the whole flow in each.

## Making it a template

- **Publish it as a GitHub template repository.** Mark the repo as a [template repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-template-repository), the same way Rukh is set up, so people can start a new product with "Use this template".
- **Keep product code in `src/mcp/tools/` only.** Everything else is plumbing, and keeping it that way means template updates merge cleanly into products built from it.
- **Ship a `.env.template`.** With SQLite there's no database to start, so the only outside things a new user needs are Stripe test keys and an authorization server.
- **Add tests.** Two tests matter most: an e2e test that mints a JWT against a stub AS, as the smoke test above did, and a unit test for `metered()`.
- **Consider these improvements later:**
  - a 30–60 second cache for `currentPlan()` and `callsThisMonth()`;
  - rate limiting with `@nestjs/throttler`, per user on `/mcp` and per IP on `/subscribe` and `/b/:token`;
  - API keys alongside OAuth, for headless agents;
  - an upgrade prompt shown in the middle of a call, using the 2026-07-28 `inputRequired` URL flow.

## Further reading

- [MCP specification, 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28)
- [TypeScript SDK v2 serving guide](https://ts.sdk.modelcontextprotocol.io/v2/serving/http)
- [SDK v1 → v2 migration guide](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/migration/upgrade-to-v2.md)
- [NestJS middleware](https://docs.nestjs.com/middleware)
- [better-sqlite3 API](https://github.com/WiseLibs/better-sqlite3/blob/master/docs/api.md)
- [SQLite date and time functions](https://www.sqlite.org/lang_datefunc.html)
- [Stripe webhooks](https://docs.stripe.com/webhooks)
- [Stripe Checkout](https://docs.stripe.com/payments/checkout)
- [Stripe customer portal](https://docs.stripe.com/customer-management)
