npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

mcp-metering

v0.1.0

Published

Reliable, idempotent usage metering for MCP servers you already run in production. Retrofit-friendly: no stack requirements, no database lock-in.

Readme

mcp-metering

Reliable, idempotent usage metering for MCP servers you already run in production.

Most of what makes usage-based billing hard isn't the payment integration — it's agreeing on what actually happened during a call, after the fact. A retry might repeat the same unit of work. A dropped connection might mean only half a response got produced. A stream might get cut right after the expensive part already ran. As one MCP builder framed it in a thread that partly motivated this project, that becomes a genuinely hard problem once retries, partial failures, streaming, and disconnecting clients enter the picture — harder, in practice, than wiring up the payment provider itself (comment in r/mcp).

This package is that one decision, extracted: an idempotent commit boundary you can drop around a tool call you've already shipped, in about three lines, without adopting any particular stack.

Quickstart

import { createMeter, memoryStore } from 'mcp-metering';

const meter = createMeter({
  store: memoryStore(), // swap for postgresStore(pool) in production
  billable: ['completed'],
  onBillable: (event) => reportToStripe(event),
});

const result = await meter.track(
  { id: idempotencyKey, tenant: customerId, type: 'search', units: 1 },
  async (commit) => {
    const data = await doTheWork();
    commit(); // the server finished producing — this is the billing boundary
    return data;
  },
);

if (result.outcome === 'executed') {
  return result.value;
}
// result.outcome === 'duplicate' — see "Retries and duplicates" below

That's the retrofit: wrap the body of an existing tool handler in meter.track(), keep returning what it already returns.

What counts as a billable event

Three states, derived from what the wrapped callback actually did:

| Callback behavior | Status | Units | |---|---|---| | Resolves | completed | last commit(units) value, or the units declared upfront | | Throws, commit() never called | failed | the units declared upfront (see below) | | Throws, commit() was called | partial | the last commit(units) value |

commit() is the commit boundary: call it once the server has finished producing the billable work, before handing it off to the client (e.g. before returning a stream). It defines "completed" as the server finished producing, not the client finished receiving — if a client aborts mid-stream after the server already did the work, that's still billed. If the client's disconnect happens before the server finishes producing, catch that inside your own work function, call commit(unitsActuallyProduced), and rethrow — that's what turns it into partial instead of failed.

commit() is fire-and-forget from your side — don't await it. Internally, track() sequences the underlying durable write and waits for it before finalizing, so the durability guarantee holds even though you never see that promise.

If commit() is called more than once, the last call wins — there's no averaging or summing.

failed keeps the declared units, not zero

A failed event is recorded with the units you declared upfront in track()'s input, not 0. This only matters if you explicitly opt failed into billable/billableByType (unusual, but not forbidden) — a 0-unit "failed but billable" event would otherwise be a silent no-op. The default billable: ['completed'] never bills failed regardless.

Retries and duplicates

Same id (your idempotency key) submitted twice: the callback runs at most once. The second call gets { outcome: 'duplicate', value: undefined } back immediately — it does not wait for an in-flight original call to finish, and it does not re-run your callback or re-emit to onBillable.

v1 does not cache or replay the original return value. If your integrator code needs to respond to a retry with the actual result (not just "already handled"), you're responsible for your own idempotent read path — e.g. look up the result from your own database by id, or return a clear "already processing" signal and let the client's own retry/polling handle it. This is a deliberate scope cut, not an oversight: caching arbitrary return values (including things like open streams) generically is a different, heavier problem than metering. Example:

const result = await meter.track({ id, tenant, type: 'search', units: 1 }, async () => runSearch());

if (result.outcome === 'duplicate') {
  return { status: 409, body: { error: 'already processed or in progress', id } };
}
return { status: 200, body: result.value };

Why duplicate exposes a StoreRecord, not a MeterEvent

A concurrent duplicate can observe the original call still in flight — the stored record's status can legitimately be 'pending' at that instant. MeterEvent (what onBillable receives) intentionally has no 'pending' state, because a settled billing event can never be pending. So the duplicate branch of TrackResult carries the store's own StoreRecord type (which includes 'pending'), not MeterEvent — this keeps MeterEvent.status a clean 3-state enum for the actual billing logic instead of forcing every onBillable implementation to handle a 'pending' case that can never reach it.

Billability policy

createMeter({
  store,
  billable: ['completed'],                          // global default
  billableByType: { report: ['completed', 'partial'] }, // per-type override
  onBillable,
});

billableByType[type], when present, replaces billable entirely for that type — it does not merge with the global list. If report is expensive per-unit and a client can cut the stream just before completed to get the work for free under a global partial-not-billable policy, give report its own entry that includes partial.

Crash recovery: the pending TTL

A process that crashes between claiming an id and finalizing it leaves that id claimed forever — without a recovery mechanism, a client's retry after the crash would get duplicate and the work would never run and never be billed, silently.

createMeter({ pendingTtlMs }) (default: 24h, matching Stripe's own idempotency key window) controls how long a 'pending' record can stay unresolved before a new call with the same id is allowed to reclaim it and actually run the callback. A claim() on:

  • a settled record (completed/partial/failed) → always duplicate, regardless of age.
  • a 'pending' record younger than pendingTtlMsduplicate (assume it's still genuinely in flight).
  • a 'pending' record older than pendingTtlMs → reclaimed, callback runs.

Set pendingTtlMs comfortably above the p99.9 latency of the operation you're metering. This is a recovery mechanism for dead processes, not a correctness guarantee against live-but-slow ones — a call that's merely slow (not crashed) and still running past the TTL can be concurrently reclaimed and re-executed by a retry, which defeats dedup for that one id. Use store.listStale(olderThanMs) to audit/alert on records stuck in 'pending' before they hit the TTL, rather than relying on the TTL alone as your only signal.

Billing failure never corrupts the record (and never overrides your callback's outcome)

The store never says billed: true before your onBillable sink has actually resolved. The write order is always:

  1. store.finalize(id, { status, units })billed is always written as false here.
  2. onBillable(event) — only if the event's status is in the billable policy.
  3. store.markBilled(id) — only if step 2 resolved without throwing.

If your sink throws, the event stays recorded with billed: false forever — nothing pretends it was billed. On the success path (outcome: 'executed'), that error is exposed as result.billingError so you can retry or alert; on the failure path (callback threw), it's only visible via the optional onError hook, since there's no result object to attach it to. Either way, a sink failure never changes whether the wrapped callback's own error propagates, and never blocks track() from settling.

store.finalize() itself failing is different: on the success path it's the only failure, so it's thrown as a MeteringStoreError — if metering can't even record what happened, better to surface that loudly than return a value with no record of it. On the failure path (callback already threw), the original error always wins; a finalize() failure there is reported only via onError, never substituted for the callback's own error.

store.claim() failing is fail-closed: it throws before your callback ever runs. This is the one point where refusing to work is actually useful — every point after this, the expensive work has already happened or is happening, so refusing to proceed would just throw away work without saving anything.

MeterStore — writing your own adapter

interface MeterStore {
  claim(input: ClaimInput): Promise<ClaimResult>;
  checkpoint(id: string, patch: { units: number }, now: Date): Promise<void>;
  finalize(id: string, patch: { status: MeterStatus; units: number }, now: Date): Promise<void>;
  markBilled(id: string, now: Date): Promise<void>;
  listStale(olderThanMs: number, now?: Date): Promise<StoreRecord[]>;
}

The one hard requirement: claim() must be atomic — a single check-and-set round trip, never a SELECT followed by a conditional INSERT. Concurrent calls with the same id are an expected, tested scenario; a select-then-insert races under that load. memoryStore() gets this for free (Node's single-threaded event loop makes a synchronous Map check-and-set atomic); postgresStore() does it with a single INSERT ... ON CONFLICT (id) DO UPDATE ... WHERE statement — see src/stores/postgres.ts.

postgresStore(pool, options?)

import { Pool } from 'pg';
import { postgresStore, createTableSQL } from 'mcp-metering';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
await pool.query(createTableSQL()); // run once, e.g. in a migration

const store = postgresStore(pool);

postgresStore() takes a structural { query(sql, params?): Promise<{ rows }> } interface, not pg's own types — pg is only an optional peerDependency for documentation; this package has zero compile-time or runtime dependency on it. Anything with a compatible .query() (a pg.Pool, a pg.Client, pglite, a pgbouncer-fronted pool) works.

createTableSQL(tableName?) returns the DDL for the events table (default name meter_events). It never runs automatically — run it yourself once, in a migration or at startup. Pass a custom table name to both createTableSQL() and postgresStore(pool, { tableName }) if you need to namespace it; the name is validated against a strict identifier pattern before being interpolated into SQL.

API reference

function createMeter(config: MeterConfig): Meter;

interface MeterConfig {
  store: MeterStore;
  billable?: MeterStatus[];                          // default: ['completed']
  billableByType?: Record<string, MeterStatus[]>;
  onBillable?: (event: MeterEvent) => void | Promise<void>; // optional — omit for ledger-only use
  onError?: (err: unknown, ctx: { phase: 'claim'|'checkpoint'|'finalize'|'sink'|'markBilled'; id: string; tenant: string; type: string }) => void;
  pendingTtlMs?: number; // default: 24h
}

interface Meter {
  track<T>(input: TrackInput, fn: (commit: CommitFn) => Promise<T>): Promise<TrackResult<T>>;
}

interface TrackInput {
  id: string;       // idempotency key
  tenant: string;
  type: string;
  units: number;
  metadata?: Record<string, unknown>; // travels to onBillable's MeterEvent — e.g. your Stripe customer id
}

type CommitFn = (units?: number) => void;

type TrackResult<T> =
  | { outcome: 'executed'; value: T; event: MeterEvent; billingError?: unknown }
  | { outcome: 'duplicate'; value: undefined; record: StoreRecord };

track() throws when the wrapped callback throws — the original error, unmodified, so whatever try/catch already surrounds your retrofitted code keeps working exactly as before. It also throws MeteringStoreError if claim() or finalize() (on the success path) fail.

What this is not

This is the metering primitive, not the boilerplate. It has no auth, no OAuth, no API keys, no quota enforcement, no rate limiting, no Stripe client — just the idempotent commit/dedup pattern. For the full stack (OAuth 2.1 + API key management + Stripe usage-based billing + rate limiting, wired together and tested) see mcp-billing-showcase.