mcp-subs: a NestJS template for paid MCP servers

Julien Béranger

+ Claude Opus 5.5

Goal

mcp-subs is a template that handles everything a paid remote Model Context Protocol (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, and pay a monthly subscription to use.

What the template provides:

  • Protocol handling through the official TypeScript SDK v2, running inside NestJS 12.
  • Auth: OAuth 2.1 according to the MCP authorization spec. 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 file through 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, 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.

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

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

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 (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

ConcernChoice
RuntimeNode.js 22 LTS (Nest 12 and the SDK both require 20 or later)
FrameworkNestJS 12, which is ESM-only, on its Express 5 platform
MCP@modelcontextprotocol/server, /express, /node
ValidationZod 4
JWT verificationjose
DatabaseSQLite through better-sqlite3 13, in WAL mode
Billingstripe
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:

{
  "type": "module",
  "scripts": {
    "build": "tsc -p .",
    "start": "node dist/main.js"
  }
}
// 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

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

// 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);
VariableExample
PUBLIC_URLhttps://mcp.example.com
DATABASE_PATH./data/app.db (default)
AUTH_ISSUERhttps://auth.example.com
STRIPE_SECRET_KEYsk_live_...
STRIPE_WEBHOOK_SECRETwhsec_...
STRIPE_PRICE_PROprice_...
PRO_PRICE_LABEL€9 / month (display only, on the pricing page)
LINK_SECRET32+ 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.

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

// 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, and access tokens whose aud claim is PUBLIC_URL/mcp;
  • Client ID Metadata Documents, 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, Auth0 or Descope. If you'd rather self-host, Keycloak 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

// 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);
        }
      },
    };
  }
}
// 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

// 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

// 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;
  }
}
// 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.

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

// 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. For local testing, forward events with the Stripe CLI:

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

Usage tracking

// 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:

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

// 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

// 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;
  }
}
// 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

// 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}`,
          }],
        };
      },
    );
  }
}
// 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 },
          };
        }),
    );
  }
}
// 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

// 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');
  }
}
// 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, 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 is the simplest option:

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

Run the app under systemd, 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 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, 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, 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