mcpcost 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +99 -2
- package/dist/claude.d.ts +65 -0
- package/dist/claude.js +118 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +292 -0
- package/dist/configs.d.ts +41 -0
- package/dist/configs.js +127 -0
- package/dist/core.d.ts +34 -0
- package/dist/core.js +162 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +9 -0
- package/dist/mcp.d.ts +10 -0
- package/dist/mcp.js +173 -0
- package/dist/payload.d.ts +16 -0
- package/dist/payload.js +17 -0
- package/dist/probe.d.ts +27 -0
- package/dist/probe.js +39 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +54 -0
- package/dist/stdio.d.ts +16 -0
- package/dist/stdio.js +45 -0
- package/dist/tokens.d.ts +13 -0
- package/dist/tokens.js +31 -0
- package/dist/types.d.ts +117 -0
- package/dist/types.js +2 -0
- package/package.json +54 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ondřej Úlehla
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,100 @@
|
|
|
1
|
-
#
|
|
1
|
+
# mcpcost
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/mcpcost) [](https://github.com/ondraulehla/mcpcost/actions/workflows/ci.yml) [](LICENSE)
|
|
4
|
+
|
|
5
|
+
**`top` for your MCP servers: how many tokens do their tool definitions cost before the first prompt?**
|
|
6
|
+
|
|
7
|
+
mcpcost is the CLI of [mcpcost](https://github.com/ondraulehla/mcpcost), the board that probes every remote server in the official MCP registry. The board tells you about servers out there. mcpcost tells you about yours.
|
|
8
|
+
|
|
9
|
+
Every MCP server you configure hands your agent its tool definitions at the start of each session. Those definitions are tokens, and you pay for them in every turn. This command starts each configured server, lists its tools and tells you what they cost.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
$ npx mcpcost --config .mcp.json --tools 3
|
|
13
|
+
|
|
14
|
+
mcpcost 0.2.0 · tool definitions your agent loads before the first prompt
|
|
15
|
+
.mcp.json
|
|
16
|
+
|
|
17
|
+
server source result tools tokens of 200k
|
|
18
|
+
────────── ───────── ─────────────────── ───── ────── ───────
|
|
19
|
+
filesystem .mcp.json alive 14 1 650 0.8 %
|
|
20
|
+
everything .mcp.json alive 13 1 075 0.5 %
|
|
21
|
+
deepwiki .mcp.json alive 3 239 0.1 %
|
|
22
|
+
github .mcp.json needs auth (Bearer)
|
|
23
|
+
apify .mcp.json needs auth (Bearer)
|
|
24
|
+
────────── ───────── ─────────────────── ───── ────── ───────
|
|
25
|
+
total 3 of 5 measured 30 2 964 1.5 %
|
|
26
|
+
|
|
27
|
+
github: Streamable HTTP error: Error POSTing to endpoint: bad request: missing required Authorization header
|
|
28
|
+
apify: Streamable HTTP error: Error POSTing to endpoint: {"error":"invalid_token", ...
|
|
29
|
+
|
|
30
|
+
filesystem: most expensive tools
|
|
31
|
+
185 read_text_file
|
|
32
|
+
162 edit_file
|
|
33
|
+
148 search_files
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
That run measured this `.mcp.json`: the filesystem and everything reference servers over stdio, DeepWiki, the GitHub MCP and Apify over HTTP. Servers that need auth are reachable but not measured until you put the header in the config.
|
|
37
|
+
|
|
38
|
+
It reads the config files of Claude Code (`.mcp.json`, `~/.claude.json`), Claude Desktop, Cursor, VS Code, Windsurf and Gemini CLI. Remote servers are probed over Streamable HTTP or SSE with the headers from your config. Local servers are started over stdio with the command, args and env from your config, the same way your client starts them.
|
|
39
|
+
|
|
40
|
+
## Options
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
mcpcost [options] measure every server in the configs it can find
|
|
44
|
+
mcpcost --config <file> measure one config file (repeatable)
|
|
45
|
+
mcpcost --url <url> measure one remote server (repeatable)
|
|
46
|
+
mcpcost --stdio "<command args>" measure one local server (repeatable)
|
|
47
|
+
|
|
48
|
+
--window <tokens> context window for the percentages (default 200000)
|
|
49
|
+
--budget <tokens> exit with code 1 when the total is above this
|
|
50
|
+
--tools <n> list the n most expensive tools of each server
|
|
51
|
+
--claude exact counts from the Anthropic count_tokens endpoint (free, needs ANTHROPIC_API_KEY)
|
|
52
|
+
--model <id> model for --claude (default claude-opus-5-5)
|
|
53
|
+
--timeout <ms> per server (default 15000)
|
|
54
|
+
--no-stdio do not start local servers, only measure remote ones
|
|
55
|
+
--json machine-readable output
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`--budget` makes it a CI check: commit your `.mcp.json`, run `npx mcpcost --budget 30000` in the pipeline, and a pull request that adds a 40 000-token server fails.
|
|
59
|
+
|
|
60
|
+
## As an MCP server
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{ "mcpServers": { "mcpcost": { "command": "npx", "args": ["-y", "mcpcost", "mcp"] } } }
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`mcpcost mcp` serves the same measurements over stdio, so an agent can check a server before it is added to a config. Four tools: `measure_server` (a remote URL or a local command, probed now), `measure_config` (every server in a config file), `lookup_server` and `search_servers` (the public board, which probes the whole registry daily). With `ANTHROPIC_API_KEY` in the environment the counts are exact. The repository's `Dockerfile` builds the same server for containers.
|
|
67
|
+
|
|
68
|
+
## How tokens are counted
|
|
69
|
+
|
|
70
|
+
Two ways.
|
|
71
|
+
|
|
72
|
+
**Exact, with `--claude`.** mcpcost asks the Anthropic `count_tokens` endpoint. It sends one request with the server's tools and one request without them. The difference is what the tools cost, as the model sees them. The endpoint is free and calls no model. It needs `ANTHROPIC_API_KEY` in the environment. You get two numbers: the tools as published, and the tools as Claude Code names them (`mcp__<server>__<tool>`, where `<server>` is the key in your config). The default model is `claude-opus-5-5`. `--budget` then applies to the exact total.
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
ANTHROPIC_API_KEY=sk-ant-… npx mcpcost --claude
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Estimate, by default.** Each tool is serialised as compact JSON with `name`, `description` and `input_schema`, and counted with the `o200k_base` tokenizer. No key is needed. The exact Claude count is about 1.9 times this estimate at the median, because Claude renders schemas in its own format and tokenises prose in its own way. The estimate is the same for every server, so servers stay comparable.
|
|
79
|
+
|
|
80
|
+
The [public board](https://mcpcost.ulehla.dev) shows both numbers for every remote server in the official registry.
|
|
81
|
+
|
|
82
|
+
## Library
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { probeRemote, probeStdio, discoverConfigs } from 'mcpcost';
|
|
86
|
+
|
|
87
|
+
const r = await probeRemote(
|
|
88
|
+
{ url: 'https://mcp.deepwiki.com/mcp', type: 'streamable-http' },
|
|
89
|
+
{ claude: { apiKey: process.env.ANTHROPIC_API_KEY!, serverName: 'deepwiki' } } // optional
|
|
90
|
+
);
|
|
91
|
+
r.status; // 'ok' | 'auth' | 'payment' | 'not_found' | 'timeout' | ...
|
|
92
|
+
r.toolsTokens; // 239, the o200k_base estimate
|
|
93
|
+
r.claude?.tokens; // 438, exact for claude-opus-5-5
|
|
94
|
+
r.claude?.tokensClaudeCode; // 462, with the mcp__deepwiki__ prefix
|
|
95
|
+
r.tools; // [{ name, tokens, bytes, descriptionChars }, ...]
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Licence
|
|
99
|
+
|
|
100
|
+
MIT
|
package/dist/claude.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact token counts from the Anthropic count_tokens endpoint. The endpoint is
|
|
3
|
+
* free and has its own rate limit. Nothing here calls a model.
|
|
4
|
+
*
|
|
5
|
+
* Method: count a request that holds one tiny marker tool plus the server's
|
|
6
|
+
* tools, and subtract the count of the same request with only the marker tool.
|
|
7
|
+
* Both requests carry the tool-use system prompt, so it cancels out. What is
|
|
8
|
+
* left is exactly what the server's tool definitions add, as this model
|
|
9
|
+
* renders and tokenizes them. No constants from the docs are needed.
|
|
10
|
+
*
|
|
11
|
+
* This file has no Node dependencies, so the Worker can import it too.
|
|
12
|
+
*/
|
|
13
|
+
import type { ToolLike } from './payload.js';
|
|
14
|
+
export declare const CLAUDE_MODEL = "claude-opus-5-5";
|
|
15
|
+
export declare const COUNT_TOKENS_URL = "https://api.anthropic.com/v1/messages/count_tokens";
|
|
16
|
+
export interface ClaudeCountOptions {
|
|
17
|
+
apiKey: string;
|
|
18
|
+
/** Model id. Default claude-opus-5-5. */
|
|
19
|
+
model?: string;
|
|
20
|
+
/** Prefix for every tool name, for example `mcp__github__` as Claude Code sends them. */
|
|
21
|
+
prefix?: string;
|
|
22
|
+
fetch?: typeof fetch;
|
|
23
|
+
/** Retries on 429 and 5xx. Default 5. */
|
|
24
|
+
retries?: number;
|
|
25
|
+
/** Timeout for one request. Default 30 s. */
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
/** Shared rate limiter from createLimiter, for many servers in one process. */
|
|
28
|
+
limiter?: Limiter;
|
|
29
|
+
}
|
|
30
|
+
export interface Limiter {
|
|
31
|
+
/** Resolves when the next request may start. */
|
|
32
|
+
wait(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Lets at most `rpm` requests start in any sliding minute. Organisations have a
|
|
36
|
+
* count_tokens limit of their own (100 a minute on a small one), and a 429
|
|
37
|
+
* costs more time than waiting for a slot.
|
|
38
|
+
*/
|
|
39
|
+
export declare function createLimiter(rpm: number): Limiter;
|
|
40
|
+
export interface ClaudeCount {
|
|
41
|
+
model: string;
|
|
42
|
+
/** Tokens the tool definitions add to a request. */
|
|
43
|
+
tokens: number;
|
|
44
|
+
}
|
|
45
|
+
/** The answer when Claude refuses to count, for example on a schema it does not accept. */
|
|
46
|
+
export declare class ClaudeCountError extends Error {
|
|
47
|
+
readonly status: number;
|
|
48
|
+
constructor(message: string, status: number);
|
|
49
|
+
}
|
|
50
|
+
/** The tool list as the Messages API takes it. Names are made safe and unique, because the API refuses duplicates. */
|
|
51
|
+
export declare function toApiTools(tools: ToolLike[], prefix?: string): Array<{
|
|
52
|
+
name: string;
|
|
53
|
+
description: string;
|
|
54
|
+
input_schema: unknown;
|
|
55
|
+
}>;
|
|
56
|
+
/** Exact tokens these tool definitions add to a request for this model. */
|
|
57
|
+
export declare function countClaudeTokens(tools: ToolLike[], options: ClaudeCountOptions): Promise<ClaudeCount>;
|
|
58
|
+
/** Claude Code names a tool mcp__<server>__<tool>. The server part is the key in the config. */
|
|
59
|
+
export declare function claudeCodePrefix(serverName: string): string;
|
|
60
|
+
/** Exact counts for a server: as published, and as Claude Code sends them. */
|
|
61
|
+
export declare function countForServer(tools: ToolLike[], serverName: string, options: ClaudeCountOptions): Promise<{
|
|
62
|
+
model: string;
|
|
63
|
+
tokens: number;
|
|
64
|
+
tokensClaudeCode: number;
|
|
65
|
+
}>;
|
package/dist/claude.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export const CLAUDE_MODEL = 'claude-opus-5-5';
|
|
2
|
+
export const COUNT_TOKENS_URL = 'https://api.anthropic.com/v1/messages/count_tokens';
|
|
3
|
+
/**
|
|
4
|
+
* Lets at most `rpm` requests start in any sliding minute. Organisations have a
|
|
5
|
+
* count_tokens limit of their own (100 a minute on a small one), and a 429
|
|
6
|
+
* costs more time than waiting for a slot.
|
|
7
|
+
*/
|
|
8
|
+
export function createLimiter(rpm) {
|
|
9
|
+
const starts = [];
|
|
10
|
+
let chain = Promise.resolve();
|
|
11
|
+
return {
|
|
12
|
+
wait() {
|
|
13
|
+
chain = chain.then(async () => {
|
|
14
|
+
for (;;) {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
while (starts.length && now - starts[0] >= 60_000)
|
|
17
|
+
starts.shift();
|
|
18
|
+
if (starts.length < rpm)
|
|
19
|
+
break;
|
|
20
|
+
await new Promise((r) => setTimeout(r, starts[0] + 60_000 - now + 20));
|
|
21
|
+
}
|
|
22
|
+
starts.push(Date.now());
|
|
23
|
+
});
|
|
24
|
+
return chain;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** The answer when Claude refuses to count, for example on a schema it does not accept. */
|
|
29
|
+
export class ClaudeCountError extends Error {
|
|
30
|
+
status;
|
|
31
|
+
constructor(message, status) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.status = status;
|
|
34
|
+
this.name = 'ClaudeCountError';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const MARKER = { name: 'mcp_pulse_marker', description: '', input_schema: { type: 'object', properties: {} } };
|
|
38
|
+
const MESSAGES = [{ role: 'user', content: 'hi' }];
|
|
39
|
+
const safeName = (name) => name.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 128) || 'tool';
|
|
40
|
+
/** The tool list as the Messages API takes it. Names are made safe and unique, because the API refuses duplicates. */
|
|
41
|
+
export function toApiTools(tools, prefix = '') {
|
|
42
|
+
const seen = new Map();
|
|
43
|
+
return tools.map((t) => {
|
|
44
|
+
let name = safeName(prefix + t.name);
|
|
45
|
+
const n = seen.get(name) ?? 0;
|
|
46
|
+
seen.set(name, n + 1);
|
|
47
|
+
if (n)
|
|
48
|
+
name = `${name.slice(0, 124)}_${n + 1}`;
|
|
49
|
+
return {
|
|
50
|
+
name,
|
|
51
|
+
description: t.description ?? '',
|
|
52
|
+
input_schema: t.inputSchema ?? { type: 'object', properties: {} }
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async function countRequest(body, options) {
|
|
57
|
+
const doFetch = options.fetch ?? fetch;
|
|
58
|
+
const retries = options.retries ?? 5;
|
|
59
|
+
for (let attempt = 0;; attempt++) {
|
|
60
|
+
if (options.limiter)
|
|
61
|
+
await options.limiter.wait();
|
|
62
|
+
let res;
|
|
63
|
+
try {
|
|
64
|
+
res = await doFetch(COUNT_TOKENS_URL, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'x-api-key': options.apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
|
|
67
|
+
body: JSON.stringify(body),
|
|
68
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000)
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
if (attempt < retries) {
|
|
73
|
+
await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
throw new ClaudeCountError(e instanceof Error ? e.message : String(e), 0);
|
|
77
|
+
}
|
|
78
|
+
if ((res.status === 429 || res.status >= 500) && attempt < retries) {
|
|
79
|
+
const wait = Number(res.headers.get('retry-after')) * 1000 || 1500 * 2 ** attempt;
|
|
80
|
+
await new Promise((r) => setTimeout(r, Math.min(wait, 30_000)));
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const json = (await res.json().catch(() => ({})));
|
|
84
|
+
if (!res.ok)
|
|
85
|
+
throw new ClaudeCountError((json.error?.message ?? `HTTP ${res.status}`).slice(0, 300), res.status);
|
|
86
|
+
return json.input_tokens ?? 0;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const baselines = new Map();
|
|
90
|
+
/** Tokens of the message plus the marker tool, once per model and process. */
|
|
91
|
+
function baseline(model, options) {
|
|
92
|
+
let p = baselines.get(model);
|
|
93
|
+
if (!p) {
|
|
94
|
+
p = countRequest({ model, messages: MESSAGES, tools: [MARKER] }, options);
|
|
95
|
+
p.catch(() => baselines.delete(model));
|
|
96
|
+
baselines.set(model, p);
|
|
97
|
+
}
|
|
98
|
+
return p;
|
|
99
|
+
}
|
|
100
|
+
/** Exact tokens these tool definitions add to a request for this model. */
|
|
101
|
+
export async function countClaudeTokens(tools, options) {
|
|
102
|
+
const model = options.model ?? CLAUDE_MODEL;
|
|
103
|
+
if (!tools.length)
|
|
104
|
+
return { model, tokens: 0 };
|
|
105
|
+
const base = await baseline(model, options);
|
|
106
|
+
const withTools = await countRequest({ model, messages: MESSAGES, tools: [MARKER, ...toApiTools(tools, options.prefix)] }, options);
|
|
107
|
+
return { model, tokens: Math.max(0, withTools - base) };
|
|
108
|
+
}
|
|
109
|
+
/** Claude Code names a tool mcp__<server>__<tool>. The server part is the key in the config. */
|
|
110
|
+
export function claudeCodePrefix(serverName) {
|
|
111
|
+
const slug = (serverName.split('/').pop() ?? serverName).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
112
|
+
return `mcp__${slug}__`;
|
|
113
|
+
}
|
|
114
|
+
/** Exact counts for a server: as published, and as Claude Code sends them. */
|
|
115
|
+
export async function countForServer(tools, serverName, options) {
|
|
116
|
+
const [plain, cc] = await Promise.all([countClaudeTokens(tools, options), countClaudeTokens(tools, { ...options, prefix: claudeCodePrefix(serverName) })]);
|
|
117
|
+
return { model: plain.model, tokens: plain.tokens, tokensClaudeCode: cc.tokens };
|
|
118
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { realpathSync } from 'node:fs';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { discoverConfigs, readConfigFile } from './configs.js';
|
|
7
|
+
import { probeRemote, VERSION } from './probe.js';
|
|
8
|
+
import { probeStdio } from './stdio.js';
|
|
9
|
+
import { TOKENIZER } from './tokens.js';
|
|
10
|
+
const HELP = `mcpcost ${VERSION}: how many tokens do your MCP servers cost before the first prompt?
|
|
11
|
+
|
|
12
|
+
Usage
|
|
13
|
+
mcpcost [options] measure every server in the configs it can find
|
|
14
|
+
mcpcost mcp serve the same measurements as an MCP server over stdio
|
|
15
|
+
mcpcost --config <file> measure one config file (repeatable)
|
|
16
|
+
mcpcost --url <url> measure one remote server (repeatable)
|
|
17
|
+
mcpcost --stdio "<command args>" measure one local server (repeatable)
|
|
18
|
+
|
|
19
|
+
Options
|
|
20
|
+
--window <tokens> context window for the percentages (default 200000)
|
|
21
|
+
--budget <tokens> exit with code 1 when the total is above this
|
|
22
|
+
--tools <n> list the n most expensive tools of each server (default 0)
|
|
23
|
+
--claude exact counts from the Anthropic count_tokens endpoint (free, needs ANTHROPIC_API_KEY)
|
|
24
|
+
--model <id> model for --claude (default claude-opus-5-5)
|
|
25
|
+
--timeout <ms> per server (default 15000)
|
|
26
|
+
--no-stdio do not start local servers, only measure remote ones
|
|
27
|
+
--json machine-readable output
|
|
28
|
+
--help, --version
|
|
29
|
+
|
|
30
|
+
Config files it looks for: .mcp.json, ~/.claude.json, Claude Desktop, Cursor, VS Code, Windsurf, Gemini CLI.
|
|
31
|
+
Without --claude, tokens are an estimate: ${TOKENIZER} over name, description and input schema of each tool.
|
|
32
|
+
With --claude, the exact count for the model is shown too, as published and with the Claude Code mcp__<server>__ prefix.
|
|
33
|
+
The same probe runs the public board at https://mcpcost.ulehla.dev`;
|
|
34
|
+
export async function main(argv) {
|
|
35
|
+
if (argv[0] === 'mcp') {
|
|
36
|
+
const { serveStdio } = await import('./mcp.js');
|
|
37
|
+
await serveStdio();
|
|
38
|
+
return new Promise(() => undefined);
|
|
39
|
+
}
|
|
40
|
+
const { values } = parseArgs({
|
|
41
|
+
args: argv,
|
|
42
|
+
options: {
|
|
43
|
+
config: { type: 'string', multiple: true },
|
|
44
|
+
url: { type: 'string', multiple: true },
|
|
45
|
+
stdio: { type: 'string', multiple: true },
|
|
46
|
+
window: { type: 'string', default: '200000' },
|
|
47
|
+
budget: { type: 'string' },
|
|
48
|
+
tools: { type: 'string', default: '0' },
|
|
49
|
+
timeout: { type: 'string', default: '15000' },
|
|
50
|
+
'no-stdio': { type: 'boolean', default: false },
|
|
51
|
+
claude: { type: 'boolean', default: false },
|
|
52
|
+
model: { type: 'string' },
|
|
53
|
+
json: { type: 'boolean', default: false },
|
|
54
|
+
help: { type: 'boolean', default: false },
|
|
55
|
+
version: { type: 'boolean', default: false }
|
|
56
|
+
},
|
|
57
|
+
allowPositionals: true
|
|
58
|
+
});
|
|
59
|
+
if (values.help) {
|
|
60
|
+
console.log(HELP);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
if (values.version) {
|
|
64
|
+
console.log(VERSION);
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
const window = Number(values.window) || 200_000;
|
|
68
|
+
const timeoutMs = Number(values.timeout) || 15_000;
|
|
69
|
+
const topTools = Number(values.tools) || 0;
|
|
70
|
+
const budget = values.budget ? Number(values.budget) : undefined;
|
|
71
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
72
|
+
if (values.claude && !apiKey) {
|
|
73
|
+
console.error('--claude needs ANTHROPIC_API_KEY in the environment. Only the free count_tokens endpoint is called.');
|
|
74
|
+
return 2;
|
|
75
|
+
}
|
|
76
|
+
const claudeFor = (serverName) => (values.claude && apiKey ? { apiKey, model: values.model, serverName } : undefined);
|
|
77
|
+
const servers = [];
|
|
78
|
+
const sources = [];
|
|
79
|
+
if (values.config?.length) {
|
|
80
|
+
for (const file of values.config) {
|
|
81
|
+
const cfg = await readConfigFile(file, 'config');
|
|
82
|
+
if (!cfg) {
|
|
83
|
+
console.error(`cannot read ${file}`);
|
|
84
|
+
return 2;
|
|
85
|
+
}
|
|
86
|
+
servers.push(...cfg.servers);
|
|
87
|
+
sources.push(file);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const url of values.url ?? []) {
|
|
91
|
+
servers.push({ name: url, source: 'command line', kind: 'remote', url, type: /\/sse\/?$/.test(url) ? 'sse' : 'streamable-http' });
|
|
92
|
+
}
|
|
93
|
+
for (const line of values.stdio ?? []) {
|
|
94
|
+
const [command, ...args] = line.trim().split(/\s+/);
|
|
95
|
+
servers.push({ name: line, source: 'command line', kind: 'stdio', command, args });
|
|
96
|
+
}
|
|
97
|
+
if (!values.config?.length && !values.url?.length && !values.stdio?.length) {
|
|
98
|
+
const found = await discoverConfigs();
|
|
99
|
+
for (const cfg of found) {
|
|
100
|
+
servers.push(...cfg.servers);
|
|
101
|
+
sources.push(`${cfg.path} (${cfg.client}, ${cfg.servers.length})`);
|
|
102
|
+
}
|
|
103
|
+
if (!found.length) {
|
|
104
|
+
console.error('No MCP config found. Pass --config, --url or --stdio. See --help.');
|
|
105
|
+
return 2;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const rows = [];
|
|
109
|
+
const running = [];
|
|
110
|
+
for (const s of servers) {
|
|
111
|
+
const row = { name: s.name, source: s.source, kind: s.kind, target: s.kind === 'remote' ? s.url : [s.command, ...(s.args ?? [])].join(' ') };
|
|
112
|
+
rows.push(row);
|
|
113
|
+
if (s.disabled) {
|
|
114
|
+
row.skipped = 'disabled in config';
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (s.kind === 'stdio' && values['no-stdio']) {
|
|
118
|
+
row.skipped = 'local server, --no-stdio';
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const job = s.kind === 'remote'
|
|
122
|
+
? probeRemote({ url: s.url, type: s.type ?? 'streamable-http' }, { timeoutMs, headers: s.headers, claude: claudeFor(s.name) })
|
|
123
|
+
: probeStdio({ command: s.command, args: s.args, env: s.env, cwd: s.cwd }, { timeoutMs, claude: claudeFor(s.name) });
|
|
124
|
+
running.push(job.then((r) => void (row.result = r)));
|
|
125
|
+
}
|
|
126
|
+
await Promise.all(running);
|
|
127
|
+
const measured = rows.filter((r) => r.result?.status === 'ok');
|
|
128
|
+
const exact = values.claude;
|
|
129
|
+
/** The number a row is ranked and budgeted by: the exact Claude count with --claude, else the estimate. */
|
|
130
|
+
const cost = (r) => (exact ? r.result?.claude?.tokens : r.result?.toolsTokens);
|
|
131
|
+
const total = measured.reduce((a, r) => a + (cost(r) ?? 0), 0);
|
|
132
|
+
const totalEstimate = measured.reduce((a, r) => a + (r.result?.toolsTokens ?? 0), 0);
|
|
133
|
+
const totalTools = measured.reduce((a, r) => a + (r.result?.toolCount ?? 0), 0);
|
|
134
|
+
const model = measured.find((r) => r.result?.claude?.model)?.result?.claude?.model ?? values.model ?? 'claude-opus-5-5';
|
|
135
|
+
rows.sort((a, b) => (cost(b) ?? -1) - (cost(a) ?? -1));
|
|
136
|
+
if (values.json) {
|
|
137
|
+
console.log(JSON.stringify({
|
|
138
|
+
version: VERSION,
|
|
139
|
+
tokenizer: TOKENIZER,
|
|
140
|
+
exact: exact ? { endpoint: 'count_tokens', model } : null,
|
|
141
|
+
window,
|
|
142
|
+
sources,
|
|
143
|
+
total: { servers: measured.length, tools: totalTools, tokens: total, tokensEstimate: totalEstimate, shareOfWindow: total / window },
|
|
144
|
+
servers: rows.map((r) => ({
|
|
145
|
+
name: r.name,
|
|
146
|
+
source: r.source,
|
|
147
|
+
kind: r.kind,
|
|
148
|
+
target: r.target,
|
|
149
|
+
skipped: r.skipped,
|
|
150
|
+
status: r.result?.status,
|
|
151
|
+
error: r.result?.error,
|
|
152
|
+
toolCount: r.result?.toolCount,
|
|
153
|
+
toolsTokens: r.result?.toolsTokens,
|
|
154
|
+
claudeTokens: r.result?.claude?.tokens,
|
|
155
|
+
claudeTokensClaudeCode: r.result?.claude?.tokensClaudeCode,
|
|
156
|
+
claudeError: r.result?.claude?.error,
|
|
157
|
+
shareOfWindow: cost(r) != null ? cost(r) / window : undefined,
|
|
158
|
+
initializeMs: r.result?.latencyMs.initialize,
|
|
159
|
+
toolsListMs: r.result?.latencyMs.toolsList,
|
|
160
|
+
protocolVersion: r.result?.protocolVersion,
|
|
161
|
+
tools: topTools > 0 ? r.result?.tools?.slice().sort((a, b) => b.tokens - a.tokens).slice(0, topTools) : undefined
|
|
162
|
+
}))
|
|
163
|
+
}, null, 2));
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
console.log(render(rows, { window, total, totalTools, topTools, sources, exact, model, cost }));
|
|
167
|
+
}
|
|
168
|
+
if (budget !== undefined && total > budget) {
|
|
169
|
+
console.error(`\nOver budget: ${fmt(total)} tokens, the budget is ${fmt(budget)}.`);
|
|
170
|
+
return 1;
|
|
171
|
+
}
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
function fmt(n) {
|
|
175
|
+
if (n === undefined || n === null)
|
|
176
|
+
return '';
|
|
177
|
+
return n.toLocaleString('en-US').replace(/,/g, ' ');
|
|
178
|
+
}
|
|
179
|
+
function pct(n, window) {
|
|
180
|
+
const p = (n / window) * 100;
|
|
181
|
+
return p < 0.1 && n > 0 ? '<0.1 %' : `${p.toFixed(1)} %`;
|
|
182
|
+
}
|
|
183
|
+
function shortSource(source) {
|
|
184
|
+
return source.replace(homedir(), '~').replace(/ \(project .*\)$/, ' (project)');
|
|
185
|
+
}
|
|
186
|
+
function statusText(r) {
|
|
187
|
+
if (r.skipped)
|
|
188
|
+
return `skipped: ${r.skipped}`;
|
|
189
|
+
const s = r.result?.status;
|
|
190
|
+
if (!s)
|
|
191
|
+
return '';
|
|
192
|
+
if (s === 'ok')
|
|
193
|
+
return 'alive';
|
|
194
|
+
if (s === 'auth')
|
|
195
|
+
return `needs auth${r.result?.authScheme ? ` (${r.result.authScheme})` : ''}`;
|
|
196
|
+
if (s === 'payment')
|
|
197
|
+
return 'payment required';
|
|
198
|
+
if (s === 'newer_protocol')
|
|
199
|
+
return 'newer protocol than this SDK';
|
|
200
|
+
if (s === 'not_found')
|
|
201
|
+
return r.kind === 'stdio' ? 'command not found' : 'not found';
|
|
202
|
+
return s.replace('_', ' ');
|
|
203
|
+
}
|
|
204
|
+
function render(rows, o) {
|
|
205
|
+
const lines = [];
|
|
206
|
+
lines.push(`mcpcost ${VERSION} · tool definitions your agent loads before the first prompt`);
|
|
207
|
+
if (o.sources.length)
|
|
208
|
+
lines.push(o.sources.map((s) => ` ${shortSource(s)}`).join('\n'));
|
|
209
|
+
lines.push('');
|
|
210
|
+
if (!rows.length) {
|
|
211
|
+
lines.push(' No servers in these configs.');
|
|
212
|
+
return lines.join('\n');
|
|
213
|
+
}
|
|
214
|
+
const table = [
|
|
215
|
+
o.exact
|
|
216
|
+
? ['server', 'source', 'result', 'tools', 'claude', 'claude code', 'o200k', `of ${Math.round(o.window / 1000)}k`]
|
|
217
|
+
: ['server', 'source', 'result', 'tools', 'tokens', `of ${Math.round(o.window / 1000)}k`]
|
|
218
|
+
];
|
|
219
|
+
for (const r of rows) {
|
|
220
|
+
const ok = r.result?.status === 'ok';
|
|
221
|
+
const c = r.result?.claude;
|
|
222
|
+
table.push([
|
|
223
|
+
r.name.length > 28 ? r.name.slice(0, 27) + '…' : r.name,
|
|
224
|
+
shortSource(r.source).replace(/^.*\//, ''),
|
|
225
|
+
statusText(r),
|
|
226
|
+
ok ? fmt(r.result?.toolCount) : '',
|
|
227
|
+
...(o.exact ? [ok ? (c?.tokens != null ? fmt(c.tokens) : c?.error ? 'refused' : '') : '', ok ? fmt(c?.tokensClaudeCode) : ''] : []),
|
|
228
|
+
ok ? fmt(r.result?.toolsTokens) : '',
|
|
229
|
+
ok && o.cost(r) != null ? pct(o.cost(r), o.window) : ''
|
|
230
|
+
]);
|
|
231
|
+
}
|
|
232
|
+
const totalEstimate = rows.reduce((a, r) => a + (r.result?.status === 'ok' ? (r.result.toolsTokens ?? 0) : 0), 0);
|
|
233
|
+
const totalCc = rows.reduce((a, r) => a + (r.result?.claude?.tokensClaudeCode ?? 0), 0);
|
|
234
|
+
table.push([
|
|
235
|
+
'total', '', `${rows.filter((r) => r.result?.status === 'ok').length} of ${rows.length} measured`, fmt(o.totalTools),
|
|
236
|
+
...(o.exact ? [fmt(o.total), fmt(totalCc)] : []),
|
|
237
|
+
fmt(totalEstimate), pct(o.total, o.window)
|
|
238
|
+
]);
|
|
239
|
+
const widths = table[0].map((_, i) => Math.max(...table.map((row) => row[i].length)));
|
|
240
|
+
const right = new Set(table[0].map((_, i) => i).filter((i) => i >= 3));
|
|
241
|
+
const line = (row) => ' ' + row.map((cell, i) => (right.has(i) ? cell.padStart(widths[i]) : cell.padEnd(widths[i]))).join(' ').trimEnd();
|
|
242
|
+
lines.push(line(table[0]));
|
|
243
|
+
lines.push(' ' + widths.map((w) => '─'.repeat(w)).join(' '));
|
|
244
|
+
for (const row of table.slice(1, -1))
|
|
245
|
+
lines.push(line(row));
|
|
246
|
+
lines.push(' ' + widths.map((w) => '─'.repeat(w)).join(' '));
|
|
247
|
+
lines.push(line(table[table.length - 1]));
|
|
248
|
+
const failed = rows.filter((r) => r.result && r.result.status !== 'ok' && r.result.error);
|
|
249
|
+
if (failed.length) {
|
|
250
|
+
lines.push('');
|
|
251
|
+
for (const r of failed)
|
|
252
|
+
lines.push(` ${r.name}: ${r.result.error.split('\n')[0].slice(0, 160)}`);
|
|
253
|
+
}
|
|
254
|
+
if (o.topTools > 0) {
|
|
255
|
+
for (const r of rows) {
|
|
256
|
+
if (!r.result?.tools?.length)
|
|
257
|
+
continue;
|
|
258
|
+
lines.push('', ` ${r.name}: most expensive tools`);
|
|
259
|
+
for (const t of [...r.result.tools].sort((a, b) => b.tokens - a.tokens).slice(0, o.topTools)) {
|
|
260
|
+
lines.push(` ${fmt(t.tokens).padStart(7)} ${t.name}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (o.exact) {
|
|
265
|
+
lines.push('', ` claude: exact count from the Anthropic count_tokens endpoint for ${o.model}. claude code: the same with the mcp__<server>__ prefix on each tool name.`);
|
|
266
|
+
lines.push(` o200k: the estimate without a key, ${TOKENIZER} over name, description and input schema. "refused": the endpoint did not accept the schema.`);
|
|
267
|
+
const refused = rows.filter((r) => r.result?.claude?.error);
|
|
268
|
+
for (const r of refused)
|
|
269
|
+
lines.push(` ${r.name}: ${r.result.claude.error.slice(0, 160)}`);
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
lines.push('', ` Estimated with ${TOKENIZER} over name, description and input schema of each tool. Claude counts about 1.9 times this. Add --claude for the exact count.`);
|
|
273
|
+
}
|
|
274
|
+
return lines.join('\n');
|
|
275
|
+
}
|
|
276
|
+
function invokedDirectly() {
|
|
277
|
+
if (!process.argv[1])
|
|
278
|
+
return false;
|
|
279
|
+
try {
|
|
280
|
+
// The bin is a symlink in node_modules/.bin, so compare real paths.
|
|
281
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (invokedDirectly()) {
|
|
288
|
+
main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
|
|
289
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
290
|
+
process.exit(2);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { RemoteTransport } from './types.js';
|
|
2
|
+
/** One server as a client config describes it. */
|
|
3
|
+
export interface ConfiguredServer {
|
|
4
|
+
name: string;
|
|
5
|
+
/** Where it came from: file path, plus the scope inside the file when there is one. */
|
|
6
|
+
source: string;
|
|
7
|
+
kind: 'remote' | 'stdio';
|
|
8
|
+
url?: string;
|
|
9
|
+
type?: RemoteTransport;
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
command?: string;
|
|
12
|
+
args?: string[];
|
|
13
|
+
env?: Record<string, string>;
|
|
14
|
+
cwd?: string;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface ConfigFile {
|
|
18
|
+
path: string;
|
|
19
|
+
/** The client that owns the file. */
|
|
20
|
+
client: string;
|
|
21
|
+
servers: ConfiguredServer[];
|
|
22
|
+
}
|
|
23
|
+
export interface DiscoverOptions {
|
|
24
|
+
cwd?: string;
|
|
25
|
+
home?: string;
|
|
26
|
+
platform?: NodeJS.Platform;
|
|
27
|
+
env?: Record<string, string | undefined>;
|
|
28
|
+
}
|
|
29
|
+
/** Expands ${VAR} and ${VAR:-default} the way Claude Code does in .mcp.json. */
|
|
30
|
+
export declare function expand(value: string, env: Record<string, string | undefined>): string;
|
|
31
|
+
/** Normalises one `{ name: {...} }` map of servers. Accepts the shapes Claude, Cursor, Windsurf and VS Code write. */
|
|
32
|
+
export declare function normalizeServers(map: Record<string, unknown> | undefined, source: string, env?: Record<string, string | undefined>): ConfiguredServer[];
|
|
33
|
+
/** Parses one config file of a known client. Returns null when the file is missing or not JSON. */
|
|
34
|
+
export declare function readConfigFile(file: string, client: string, options?: DiscoverOptions): Promise<ConfigFile | null>;
|
|
35
|
+
/** The places the common clients keep their MCP config. */
|
|
36
|
+
export declare function knownConfigPaths(options?: DiscoverOptions): Array<{
|
|
37
|
+
path: string;
|
|
38
|
+
client: string;
|
|
39
|
+
}>;
|
|
40
|
+
/** Reads every known config file that exists. */
|
|
41
|
+
export declare function discoverConfigs(options?: DiscoverOptions): Promise<ConfigFile[]>;
|