@sms-florin/mcpmeter
v0.2.0
Published
Drop-in usage metering and rate limiting for MCP servers. No database, no config — one function call per tool invocation.
Maintainers
Readme
mcpmeter
Drop-in usage metering and rate limiting for MCP servers. No database, no config file, no account to create — import it, call two functions, done.
import { Meter } from "@sms-florin/mcpmeter";
const meter = new Meter({ dailyLimit: 1000 });
// inside your tool handler, before doing the real work:
if (!meter.record("my_tool")) {
return { error: "Daily limit reached, try again tomorrow." };
}That's the whole quickstart. meter.stats() gives you today / last 7 days /
last 30 days / total, broken down per tool name — enough to answer "is
anyone actually using this?" without standing up analytics infrastructure.
Why this exists
Every MCP server that gets real traffic eventually needs two things it
didn't ship with on day one: a way to see how much it's actually being
called, and a way to stop a runaway caller (bug or abuse) from costing real
money or locking up a shared resource. Building that twice — once as a quick
hack, once properly after something breaks — is a waste of an afternoon.
mcpmeter is that afternoon, done once, with the honest tradeoffs written
down.
What it does NOT do
- No persistence by default for rate limits.
RateLimiterandDailyLimiterare in-memory — counts reset on process restart. If you're guarding something that costs real money per call, that's not enough on its own; pair it with your own persistent ceiling (a row in whatever database you already have).UsageStore's stats, by contrast, do support optional JSON-file persistence viaPersistentUsageStoreRegistry, since losing a few hours of usage counts is low-stakes in a way losing a rate limit isn't. - No distributed/multi-process coordination. Each process has its own counters. Fine for a single hosted instance; not a fit for a horizontally-scaled fleet without your own shared store behind it.
- No dashboard, no UI.
stats()returns plain JSON — wire it into whatever you already use to look at logs/metrics, or expose it as its own MCP tool (get_usage_stats) the way the examples below do.
API
Meter — the 90% case
const meter = new Meter({ dailyLimit?: number });
meter.record(toolName: string, opts?: { client?, durationMs?, error? }): boolean // false if dailyLimit exceeded
meter.wrap(toolName, fn, opts?): Promise // times fn, records errors, throws past dailyLimit
meter.stats(): UsageStatsLatency, errors and per-client breakdown
record() takes an optional second argument; whatever you pass shows up
under stats().detail for the last 7 and 30 days:
meter.record("my_tool", { client: "claude-desktop", durationMs: 120, error: false });
// or let mcpmeter time the handler and catch failures for you:
const result = await meter.wrap("my_tool", () => doWork(), { client: "claude-desktop" });
meter.stats().detail.last7Days;
// { tools: { my_tool: { calls: 40, errors: 2, avgMs: 118, maxMs: 930 } },
// clients: { "claude-desktop": 31, cursor: 9 } }avgMs/maxMs are null for tools that never reported a duration. All
optional: plain record(toolName) works exactly as before, and usage files
written by older versions still load.
UsageStore / PersistentUsageStoreRegistry — usage counts only
Use these directly when you need per-caller breakdowns (multi-tenant hosted server) rather than one global counter.
import { PersistentUsageStoreRegistry } from "@sms-florin/mcpmeter";
const usage = new PersistentUsageStoreRegistry("./data/usage.json");
await usage.load(); // on startup
// per request, keyed by whatever identifies the caller (API key hash, etc.)
usage.record(callerKeyHash, toolName, { client, durationMs, error }); // last arg optional
// periodically (e.g. every 30s) — cheap no-op if nothing changed
await usage.save();
// expose as a tool:
usage.for(callerKeyHash).stats();RateLimiter / DailyLimiter — limits only
import { RateLimiter, DailyLimiter } from "@sms-florin/mcpmeter";
const rl = new RateLimiter();
rl.check(bucketKey, max, windowMs): boolean
rl.remaining(bucketKey, max, windowMs): number
const daily = new DailyLimiter();
daily.checkGlobal(name, max): boolean // whole-service ceiling
daily.checkPerCaller(name, callerId, max, windowMs): boolean // per-caller windowCall .sweep() periodically (e.g. hourly) on either to drop stale entries so
memory doesn't grow unbounded in a long-running process.
Install
npm install @sms-florin/mcpmeterZero required dependencies. TypeScript types included.
License
MIT
