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, andword_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
401with aresource_metadatachallenge. - The discovery document is served.
- A token issued for a different audience is rejected.
initialize,tools/listandtools/callall 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
Hostheader gets a403. - A webhook with a bad signature gets a
400. - The pricing page is served at
/. - The
accounttool 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.createdandcustomer.subscription.createdwebhooks foralice@example.com, a user who connects with a verifiedAlice@Example.comin their token is linked automatically and getspro. - A matching email that the authorization server marks as unverified (
email_verified: false) is not linked, and that user stays onfree.
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.
createMcpHandlerserves 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 resolvedUser, 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 22 LTS (Nest 12 and the SDK both require 20 or later) |
| Framework | NestJS 12, which is ESM-only, on its Express 5 platform |
| MCP | @modelcontextprotocol/server, /express, /node |
| Validation | Zod 4 |
| JWT verification | jose |
| Database | SQLite through better-sqlite3 13, in WAL mode |
| Billing | stripe |
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-sqlite3package.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 meConfiguration
// 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.
-- 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 = WALlets tool calls keep reading while a Stripe webhook writes.synchronous = NORMALis safe in WAL mode and much faster than the default.foreign_keys = ONenforces foreign keys, which SQLite doesn't do by default.busy_timeoutwaits 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
resourceparameter from RFC 8707, and access tokens whoseaudclaim isPUBLIC_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
emailclaim 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_verifiedisfalse. - 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/webhookUsage 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:
- Create
src/mcp/tools/my.tool.tsas an@Injectable()class that implementsMcpTool, and inject whatever services it needs. - 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 acostas well, so thatusage_eventsrecords your margin. - Add the class to
TOOLSinmcp.module.ts, and add its module toimportsif 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
selectionCostmapped tocostandselectedFilespassed inmeta.
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_PATHon 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-waland-shmfiles 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 plaincpwhile the app is running; - run a single instance.
Testing checklist
curl -i -X POST $PUBLIC_URL/mcpshould return401, withresource_metadata=in theWWW-Authenticateheader.curl $PUBLIC_URL/.well-known/oauth-protected-resource/mcpshould list your AS underauthorization_servers.- Connect with the MCP Inspector, complete the login, and call
account. - Call
word_statsmore times than the free limit allows (lowerPLAN_LIMITS.freeto make this quick). The error should include a/b/…link. - Open the link, pay with test card
4242 4242 4242 4242, and check thataccountthen reportspro. - Open the new
accountlink and check that it lands on the Customer Portal. Cancel there, and check that the plan returns tofreeonce the period ends. - Pay before connecting: subscribe from
/with a fresh email, then connect a new account that signs in with the same email.accountshould reportprostraight away. - 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()andcallsThisMonth(); - rate limiting with
@nestjs/throttler, per user on/mcpand per IP on/subscribeand/b/:token; - API keys alongside OAuth, for headless agents;
- an upgrade prompt shown in the middle of a call, using the 2026-07-28
inputRequiredURL flow.
- a 30–60 second cache for