# mcpcost [![CI](https://github.com/ondraulehla/mcpcost/actions/workflows/ci.yml/badge.svg)](https://github.com/ondraulehla/mcpcost/actions/workflows/ci.yml) [![npm mcpcost](https://img.shields.io/npm/v/mcpcost?label=mcpcost)](https://www.npmjs.com/package/mcpcost) [![license](https://img.shields.io/github/license/ondraulehla/mcpcost)](LICENSE) [![Glama](https://glama.ai/mcp/servers/ondraulehla/mcpcost/badges/score.svg)](https://glama.ai/mcp/servers/ondraulehla/mcpcost) **Is that MCP server alive, and what does it cost your context window?** **Live board: [mcpcost.ulehla.dev](https://mcpcost.ulehla.dev)** mcpcost probes every remote server in the [official MCP registry](https://registry.modelcontextprotocol.io) and records, for each one: - whether it answers `initialize` and `tools/list`, and how fast - the protocol version it negotiates - whether it needs auth, which scheme (from `WWW-Authenticate`), and how a client gets in: OAuth with dynamic client registration, OAuth with a client id from the publisher, or a key, read from the server's public OAuth metadata - whether it asks for payment (HTTP 402) - how many tools it has and how many tokens their definitions cost ## mcpcost: what do your own servers cost? ```bash npx mcpcost # reads Claude Code, Claude Desktop, Cursor, VS Code, Windsurf and Gemini CLI configs npx mcpcost --config .mcp.json # one file npx mcpcost --budget 30000 # exit 1 when the total is above the budget, for CI npx mcpcost --claude # exact counts from the Anthropic count_tokens endpoint (free, needs ANTHROPIC_API_KEY) npx mcpcost mcp # the same measurements as an MCP server over stdio, for your agent ``` It starts every configured server the way your client does, lists the tools and prints the token cost per server and in total. See [packages/mcpcost](packages/mcpcost/README.md). ### Servers that need auth A server that answers 401 is reachable but not measured until it gets credentials. Store them once; every later measurement of that URL uses them. ```bash npx mcpcost auth https://mcp.linear.app/mcp # OAuth: register, consent in the browser, store the tokens npx mcpcost auth https://api.githubcopilot.com/mcp/ --header "Authorization: Bearer ghp_…" # an API key instead npx mcpcost auth --list # what is stored, without the secrets ``` The OAuth flow follows the MCP authorization spec: discovery, dynamic client registration, PKCE, a callback on 127.0.0.1. The consent screen is the one step that needs a person. After it, the tokens live in `~/.mcpcost/credentials.json` (mode 0600, or `$MCPCOST_CREDENTIALS`), `npx mcpcost` sends them for that URL, and a refreshed token goes back to the file. The board does the same: `npm run credentials:push` copies the file into the D1 table `credentials`; the daily run reads it before the probe, writes the refreshed tokens back after it, and marks the server "measured with credentials". A refresh token that a service revokes or lets expire needs the consent step again. Without credentials, the board still shows a tool set for many gated servers: tool definitions do not depend on the token, so when the publisher's repository holds a package on npm or PyPI, `probe/derive-packages.ts` finds it and the weekly container run measures it with placeholder values. The server page then says "from the publisher's package". `probe/derived-aliases.json` maps the few servers whose registry entry names no repository. ### In CI, as a GitHub Action [![GitHub Marketplace](https://img.shields.io/badge/Marketplace-mcpcost-e8571f?logo=github)](https://github.com/marketplace/actions/mcpcost-token-budget-for-mcp-servers) ```yaml - uses: ondraulehla/mcpcost@v1 with: config: .mcp.json # one or more config files, one per line budget: 30000 # fail when the tool definitions cost more than this ``` The job summary gets a table with every server, its result and its cost, plus the most expensive tools. A pull request that adds a 40 000-token server fails the check. Set `claude: true` and put `ANTHROPIC_API_KEY` in `env` for exact counts from the free count_tokens endpoint; the default is the o200k_base estimate. Outputs: `tokens`, `servers`, `over-budget`. On a pull request the action also measures the same config files on the base branch and posts one comment with the difference: servers added, removed and changed, the token delta per server, the new total and the budget verdict. The comment is updated on every push, never duplicated. The job needs `pull-requests: write` for it; without that permission the step warns and the check still runs. Set `comment: false` to turn it off. A complete workflow is in [.github/examples/mcp-budget.yml](.github/examples/mcp-budget.yml). ### In Claude Code, as a plugin In a Claude Code session: ``` /plugin install mcpcost --marketplace ondraulehla/mcpcost ``` Or from the shell: `claude plugin marketplace add ondraulehla/mcpcost`, then `claude plugin install mcpcost@mcpcost`. The plugin adds one skill, `/mcpcost:measure`, which runs `npx mcpcost` on your configs and reads the result, and the board's MCP server, so Claude can look up or probe a server before it goes into a config. What the plugin itself costs: one skill description in the context, and four tools that Claude Code defers behind tool search. ## The board [mcpcost.ulehla.dev](https://mcpcost.ulehla.dev) is rendered on the edge from a D1 database and refreshes every night. The only scripts it loads are a theme switch and the Cloudflare Web Analytics beacon, which counts page views without cookies. - **Overview**: the share of servers that answer, token cost percentiles, and a form to check any server - **Check**: probes one URL live, with the same steps as the daily run; the exact token count runs in the browser - **Statistics**: results by cause, token cost and tool count percentiles, the heaviest tool sets, protocol versions, auth schemes, transports, biggest publishers - **Servers**: full-text search over name, title, description and host (word prefixes, so `git copilot` finds the GitHub Copilot servers), filters by result, protocol and transport, seven sorts, and every filter is a plain URL you can share - **Server page**: status, latency, protocol, capabilities, the most expensive tools, servers with the same tool set, history, the badge, and e-mail alerts: a mail when the server stops answering, comes back, or its tool set or token cost changes - **Compare**: two to five servers side by side, status, access, tools, exact tokens, share of a 200k window, latency, with the cheapest and the spread; `/compare?s=&s=`, linked from every server page - **Track**: a server that is not in the registry can be tracked from the check page; the daily probe then measures it under the name `url//` - **Hosts**: the 500 biggest publishers with how many distinct tool sets they actually serve - **Report**: one page a month, *The state of the MCP registry*, read from the daily runs, with a CC BY 4.0 dataset of every server ## For agents and scripts - JSON: `/api/s/.json`, `/api/lookup.json?url=…` (the board's row for an endpoint URL, no probe), `/api/servers.json?q=…&status=ok&sort=tokens`, `/api/check.json?url=…`, `/api/summary.json`. The list API takes at most 6 requests in 10 seconds from one address; its answers are cached for three hours. - MCP server over Streamable HTTP, no auth: `https://mcpcost.ulehla.dev/mcp` with the tools `lookup_server`, `search_servers`, `compare_servers`, `check_server`, `registry_summary`. The manifest for the registry is `site/server.json`. - `/llms.txt` lists all of it. `/tokens` answers the general question: how many context tokens does an MCP server cost. Each server on the board gets a page and a badge: ```markdown [![mcpcost](https://mcpcost.ulehla.dev/badge/io.github.you/your-server.svg)](https://mcpcost.ulehla.dev/s/io.github.you/your-server) ``` ## First full run, 2026-10-03 The registry held 38 942 latest entries. 24 294 of them have a remote endpoint, and the probe connected to all of them in 40 minutes. | Result | Servers | Share | |---|---:|---:| | alive: `initialize` and `tools/list` succeed | 12 975 | 53 % | | needs auth (401, 403) | 5 836 | 24 % | | not MCP: HTTP 200 but no valid MCP reply | 1 800 | 7 % | | not found (404, 405, 410) | 881 | 4 % | | no DNS | 728 | 3 % | | rate limited (429) | 500 | 2 % | | payment required (402) | 434 | 2 % | | server error (5xx) | 349 | 1 % | | other | 307 | 1 % | | TLS error | 259 | 1 % | | timeout | 204 | 1 % | | refused | 21 | 0 % | Of the 12 975 servers that answered: - the median tool set costs 1 727 tokens, the 90th percentile 7 429, the 99th 28 200 - 208 servers cost more than 20 000 tokens, 46 more than 50 000, and 3 more than a whole 200k window - the heaviest is `com.replynodes/mcp`: 66 tools, 431 925 tokens - the median server has 8 tools, the largest 1 385 - median `initialize` latency is 455 ms, the 90th percentile 1.5 s - 60 % negotiate protocol `2025-11-25`, 24 % still `2025-03-26`, 4 % `2024-11-05` - 122 servers answer with protocol `2026-07-28`, which the current TypeScript SDK rejects The registry is lumpy. 16 161 of 16 534 hosts publish one server. Five hosts publish more than 6 000 entries between them: one Workers subdomain with 2 365 near-identical entries, a gateway with 1 716 entries of which 1 321 return a JSON-RPC error on `initialize`, and a host with 276 entries that all answer 402. ## The monthly report and the dataset [/report/2026-10](https://mcpcost.ulehla.dev/report/2026-10) is *The state of the MCP registry, October 2026*: the registry in numbers, what changed between the first full run and the run of 2026-10-24, token cost, auth, the lumps, protocol versions, what the registry does not catch, and the method in six sentences. The page reads only the `runs` table (every run of the month, one query) and is cached for a day, so it costs nothing on the free tier. `/report` lists the months; `site/src/lib/reports.ts` has one entry per report with the day of the run it is pinned to. The dataset that goes with it, `data/mcpcost-2026-10.json.gz` on the site, has one row per probed server: name, host, transport, status, HTTP status, auth scheme and kind, protocol version, tool count, the o200k estimate, the exact Claude counts and the probe time. A row's numbers are what `/api/s/.json` gives for that server on that day. The licence is CC BY 4.0; the document carries the licence, the field list and a citation at the top. `probe/dataset.ts` writes `data/latest/dataset.json` and its gzip on every daily run (kept in the run artifact), and `--publish site/public/data/mcpcost-YYYY-MM.json.gz` copies the gzip into the site when it is under 10 MB: a static file is served before the Worker and costs no request. A bigger file would go out as a GitHub release asset instead. To publish a month: run the probe or download the run artifact into `data/latest`, run `node --import tsx probe/dataset.ts --publish site/public/data/mcpcost-YYYY-MM.json.gz`, add the month to `reports.ts`, write the page, and commit the file with it. The same run goes out as re-check evidence for the registry, in the row shape agreed in [modelcontextprotocol/registry#1715](https://github.com/modelcontextprotocol/registry/issues/1715): `entry`, `checked_at`, `check` (endpoint or package), `state` (alive, auth, transient, dead, unknown) and `evidence`. `probe/dataset.ts` writes it daily next to the dataset, and the month's copy is `/data/mcpcost-evidence-YYYY-MM.json.gz`. ## How it works ``` registry ──▶ probe/run.ts ──▶ data/raw/probe-run.json │ probe/build-data.ts │ data/latest/{servers,summary,hosts}.json │ probe/to-sql.ts ──▶ import.sql ──▶ D1 │ site/ (Astro on Workers) ◀────────┘ ``` - `packages/mcpcost/` the library and the CLI `mcpcost`: registry client, remote and stdio probe, config discovery, token counting - `probe/` the batch runner and the data pipeline - `site/` the board: Astro 7 with the Cloudflare adapter on Workers, rendered from D1 with FTS5 search, SVG badges, no client JavaScript beyond the theme switch and the analytics beacon - `.github/workflows/probe.yml` runs the whole pipeline daily: probe, counts, `build-data`, `to-sql`, the D1 import. The run's `summary.json`, `servers.json` and `hosts.json` go up as a workflow artifact; the next run diffs against it and imports only the rows that changed, inside the free tier's 100 000 row writes a day - `.github/workflows/deploy.yml` deploys the site when `site/` changes ## Methodology - **Transport.** The probe uses the official TypeScript SDK as a client. Streamable HTTP first, SSE when that is the only remote. - **No GET stream.** The probe answers 405 to the SDK's standalone GET, so it never opens a server-to-client stream. The probe needs no server-initiated messages, and some servers stall `tools/list` while that stream is open (DeepWiki held it for 15 s and never answered the POST meanwhile). - **Exact token count.** The daily run asks the Anthropic `count_tokens` endpoint for every alive server, and the Gemini `countTokens` endpoint when `GEMINI_API_KEY` is set (the CLI: `--gemini`). Anthropic first: one request with the server's tools plus a marker tool, one with the marker tool only, and the difference is what the tools cost in `claude-opus-5-5`. A second count uses Claude Code's `mcp____` prefix on each tool name. The endpoint is free and calls no model. A server is counted again only when its tool definitions change. About 5 % of servers publish a schema the endpoint refuses; they show the estimate and the reason. - **Estimate.** Each tool is serialised as compact JSON with `name`, `description` and `input_schema`, and counted with the `o200k_base` tokenizer. The exact count is about 1.9 times the estimate at the median. The estimate needs no key and is the same for every server. - **Same tool set.** A hash of the sorted tool names and schema sizes marks servers that serve identical tools under different names. - **Timeouts.** 15 seconds per request. At most one probe runs against a host at a time. - **Getting access.** For a server that answers 401 or 403, the probe reads the OAuth protected-resource metadata (RFC 9728) and the first authorization server's metadata (RFC 8414, then OpenID discovery): authorization servers, scopes, the publisher's guide, dynamic client registration (RFC 7591), PKCE, grant types. The server page shows it as "Getting access", the JSON API as `authKind` and `authMetadata`. Two to four small requests, no token, no consent; the probe result does not change. - **Identification.** Every request carries the User-Agent `mcpcost/ (+https://github.com/ondraulehla/mcpcost)`. To exclude a server, open an issue with its registry name. ## Run it ```bash npm install npm run probe -- --sample 200 --per-host 2 # a sample, results in data/samples/ npm run probe -- --all --out data/raw/probe-run.json node --import tsx probe/build-data.ts --in data/raw/probe-run.json --out data/latest node --import tsx probe/to-sql.ts --in data/latest --out data/latest/import.sql npm test --workspaces --if-present ``` Site, locally against a local D1: ```bash cd site npm run db:schema:local && npm run db:import:local npm run dev ``` The alert mails need a sending domain: either Cloudflare Email Sending (Workers Paid plan, then the `send_email` binding in `site/wrangler.jsonc`) or Resend (verify the domain there and `npx wrangler secret put RESEND_API_KEY`). Without one, the watch form says that the confirmation mail could not be sent. Deploying needs a Cloudflare account with a D1 database named `mcpcost` (`wrangler d1 create mcpcost`, then put the id in `site/wrangler.jsonc`). The workflows expect the repository secrets `CLOUDFLARE_API_TOKEN` (Workers Scripts: Edit, D1: Edit) and `CLOUDFLARE_ACCOUNT_ID`. Both workflows apply `site/schema.sql` and the pending files in `site/migrations/` on every run. The two indexes on the shown token count (migrations 007 and 008) are the exception: each build writes a row per server, so the probe workflow applies one a day before the import and gives the import the rest of the write budget. A new database needs them applied by hand once. ### The edge rules The Workers Free plan counts every request the Worker answers, a 403 as much as a page. On 2026-10-22 and 23 scrapers with Chrome user agents on hosting addresses (Meta's cloud, Alibaba Cloud, RapidSeedbox) made 4 000 list variants an hour and spent the account's 100 000 requests a day, although the Worker refused them. What stops them must run before the Worker: `site/edge/rules.mjs` writes the zone's WAF rules through the API, idempotently. - a custom rule that blocks Meta's training crawler, `meta-externalagent`, on the whole host: a verified bot that ignores robots.txt, made 28 000 of the Worker's 31 000 requests in the twelve hours to 2026-10-24 05:42 UTC, and sends no readers (the link preview `facebookexternalhit` is not blocked) - a custom rule that blocks those networks on the whole host, verified bots excepted - a custom rule that gives every list variant (`/servers?…`) from anything but a verified bot a managed challenge; a browser passes it once, a script does not. The list API (`/api/servers.json`) is not in it: scripts and agents cannot pass a challenge, so the API stays open under the rate limiting rule - a custom rule that blocks a verified bot on the list variants: robots.txt disallows them for everyone, search engines never ask, and Meta's crawler (meta-externalagent behind a Chrome agent string) walked 3 000 of them an hour on 2026-10-23 - the one rate limiting rule of the Free plan: more than six list variants in ten seconds from one address is blocked for ten seconds ```bash CLOUDFLARE_API_TOKEN=… node site/edge/rules.mjs --dry-run # shows what it would write CLOUDFLARE_API_TOKEN=… node site/edge/rules.mjs ``` The Edge rules workflow (`.github/workflows/edge.yml`) runs the script when the file changes on main, or by hand, with the `CLOUDFLARE_ZONE_TOKEN` secret: a token with `Zone: Zone WAF: Edit` and `Zone: Zone: Read` on the zone. The probe's token has neither. The middleware in `site/src/middleware.ts` keeps the same refusals as a second layer, for the workers.dev host the zone rules do not see. ### The watch `site/edge/watch.mjs` says who read the board in a window of time and what it cost: the Worker's requests and D1 rows per hour from the account's analytics, and the networks, agents and URL shapes behind them from the Worker's logs. The Watch the edge workflow (`.github/workflows/watch.yml`) runs it every hour, over the time since the previous scheduled run (GitHub runs a schedule late and skips hours at times); the thresholds apply to the worst hour of that window. A threshold crossed (5 000 Worker requests an hour, 800 an hour from one network whose agents do not name a crawler, 300 list variants an hour from one network whatever it calls itself, a million D1 rows an hour; the `WATCH_*` variables) turns the run red and opens the issue labelled `edge-watch`, or adds the hour to the open one. The tables name the network; its ASN goes into `SCRAPER_ASNS` in `site/edge/rules.mjs`, and the Edge rules workflow blocks it before the Worker. The report also names the D1 queries that read the most rows in those hours. With `Zone: Analytics: Read` on the zone token, it shows what the zone stopped before the Worker as well. ```bash CLOUDFLARE_API_TOKEN=… CLOUDFLARE_ACCOUNT_ID=… node site/edge/watch.mjs # the last hour CLOUDFLARE_API_TOKEN=… CLOUDFLARE_ACCOUNT_ID=… node site/edge/watch.mjs 2026-10-22T22:00:00Z 2026-10-23T06:00:00Z # one night ``` ### The old host The site was mcp-pulse.ulehla.dev until 2026-10-15. The old host stays a route of the Worker, and the middleware in `site/src/middleware.ts` answers every request on it with a 301 to mcpcost.ulehla.dev. Static assets are served before the Worker, and the Astro adapter serves prerendered pages before the middleware too, so a file in `site/public/` or a prerendered page answers 200 on the old host. `run_worker_first` in `wrangler.jsonc` does not change that. So robots.txt, llms.txt and the methodology page are routes, and every page a crawler reads goes through the middleware. Only the favicon, the fonts under `/_astro/` and the search engine verification file remain static. To redirect those as well, with no Worker invocation at all, add a Redirect Rule to the ulehla.dev zone in the Cloudflare dashboard (Rules, Redirect Rules, Create rule): - When incoming requests match: Hostname equals `mcp-pulse.ulehla.dev` - Then: Dynamic redirect, expression `concat("https://mcpcost.ulehla.dev", http.request.uri.path)`, status code 301, preserve query string on Redirect Rules run before the Worker and before static assets, so the rule covers every path. The middleware stays as the fallback for the workers.dev host, which the zone rule does not see. ## Licence MIT