mcpcost 0.0.0-stage → 0.3.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/auth-cli.d.ts +1 -0
- package/dist/auth-cli.js +194 -0
- package/dist/claude.d.ts +65 -0
- package/dist/claude.js +118 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +364 -0
- package/dist/configs.d.ts +41 -0
- package/dist/configs.js +127 -0
- package/dist/core.d.ts +37 -0
- package/dist/core.js +162 -0
- package/dist/credentials.d.ts +52 -0
- package/dist/credentials.js +163 -0
- package/dist/gemini.d.ts +45 -0
- package/dist/gemini.js +86 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +11 -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 +37 -0
- package/dist/probe.js +57 -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 +46 -0
- package/dist/tokens.d.ts +13 -0
- package/dist/tokens.js +31 -0
- package/dist/types.d.ts +128 -0
- package/dist/types.js +2 -0
- package/package.json +54 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
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
|
+
import { loadCredentials, saveCredentials, credentialsFor, defaultCredentialsPath } from './credentials.js';
|
|
11
|
+
const HELP = `mcpcost ${VERSION}: how many tokens do your MCP servers cost before the first prompt?
|
|
12
|
+
|
|
13
|
+
Usage
|
|
14
|
+
mcpcost [options] measure every server in the configs it can find
|
|
15
|
+
mcpcost mcp serve the same measurements as an MCP server over stdio
|
|
16
|
+
mcpcost auth <url> store credentials for a server that needs auth (see mcpcost auth --help)
|
|
17
|
+
mcpcost --config <file> measure one config file (repeatable)
|
|
18
|
+
mcpcost --url <url> measure one remote server (repeatable)
|
|
19
|
+
mcpcost --stdio "<command args>" measure one local server (repeatable)
|
|
20
|
+
|
|
21
|
+
Options
|
|
22
|
+
--window <tokens> context window for the percentages (default 200000)
|
|
23
|
+
--budget <tokens> exit with code 1 when the total is above this
|
|
24
|
+
--tools <n> list the n most expensive tools of each server (default 0)
|
|
25
|
+
--claude exact counts from the Anthropic count_tokens endpoint (free, needs ANTHROPIC_API_KEY)
|
|
26
|
+
--model <id> model for --claude (default claude-opus-5-5)
|
|
27
|
+
--gemini exact counts from the Gemini countTokens endpoint (free, needs GEMINI_API_KEY)
|
|
28
|
+
--gemini-model <id> model for --gemini (default gemini-3.8-flash)
|
|
29
|
+
--timeout <ms> per server (default 15000)
|
|
30
|
+
--no-stdio do not start local servers, only measure remote ones
|
|
31
|
+
--credentials <file> credentials for servers that need auth (default ~/.mcpcost/credentials.json)
|
|
32
|
+
--json machine-readable output
|
|
33
|
+
--help, --version
|
|
34
|
+
|
|
35
|
+
Config files it looks for: .mcp.json, ~/.claude.json, Claude Desktop, Cursor, VS Code, Windsurf, Gemini CLI.
|
|
36
|
+
Without --claude, tokens are an estimate: ${TOKENIZER} over name, description and input schema of each tool.
|
|
37
|
+
With --claude, the exact count for the model is shown too, as published and with the Claude Code mcp__<server>__ prefix.
|
|
38
|
+
With --gemini, the exact count for Gemini is shown too. With both, --budget and the percentages use the Claude count.
|
|
39
|
+
The same probe runs the public board at https://mcpcost.ulehla.dev`;
|
|
40
|
+
export async function main(argv) {
|
|
41
|
+
if (argv[0] === 'mcp') {
|
|
42
|
+
const { serveStdio } = await import('./mcp.js');
|
|
43
|
+
await serveStdio();
|
|
44
|
+
return new Promise(() => undefined);
|
|
45
|
+
}
|
|
46
|
+
if (argv[0] === 'auth') {
|
|
47
|
+
const { authCommand } = await import('./auth-cli.js');
|
|
48
|
+
return authCommand(argv.slice(1));
|
|
49
|
+
}
|
|
50
|
+
const { values } = parseArgs({
|
|
51
|
+
args: argv,
|
|
52
|
+
options: {
|
|
53
|
+
config: { type: 'string', multiple: true },
|
|
54
|
+
url: { type: 'string', multiple: true },
|
|
55
|
+
stdio: { type: 'string', multiple: true },
|
|
56
|
+
window: { type: 'string', default: '200000' },
|
|
57
|
+
budget: { type: 'string' },
|
|
58
|
+
tools: { type: 'string', default: '0' },
|
|
59
|
+
timeout: { type: 'string', default: '15000' },
|
|
60
|
+
'no-stdio': { type: 'boolean', default: false },
|
|
61
|
+
credentials: { type: 'string' },
|
|
62
|
+
claude: { type: 'boolean', default: false },
|
|
63
|
+
model: { type: 'string' },
|
|
64
|
+
gemini: { type: 'boolean', default: false },
|
|
65
|
+
'gemini-model': { type: 'string' },
|
|
66
|
+
json: { type: 'boolean', default: false },
|
|
67
|
+
help: { type: 'boolean', default: false },
|
|
68
|
+
version: { type: 'boolean', default: false }
|
|
69
|
+
},
|
|
70
|
+
allowPositionals: true
|
|
71
|
+
});
|
|
72
|
+
if (values.help) {
|
|
73
|
+
console.log(HELP);
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
if (values.version) {
|
|
77
|
+
console.log(VERSION);
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
const window = Number(values.window) || 200_000;
|
|
81
|
+
const timeoutMs = Number(values.timeout) || 15_000;
|
|
82
|
+
const topTools = Number(values.tools) || 0;
|
|
83
|
+
const budget = values.budget ? Number(values.budget) : undefined;
|
|
84
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
85
|
+
if (values.claude && !apiKey) {
|
|
86
|
+
console.error('--claude needs ANTHROPIC_API_KEY in the environment. Only the free count_tokens endpoint is called.');
|
|
87
|
+
return 2;
|
|
88
|
+
}
|
|
89
|
+
const claudeFor = (serverName) => (values.claude && apiKey ? { apiKey, model: values.model, serverName } : undefined);
|
|
90
|
+
const geminiKey = process.env.GEMINI_API_KEY;
|
|
91
|
+
if (values.gemini && !geminiKey) {
|
|
92
|
+
console.error('--gemini needs GEMINI_API_KEY in the environment. Only the free countTokens endpoint is called.');
|
|
93
|
+
return 2;
|
|
94
|
+
}
|
|
95
|
+
const gemini = values.gemini && geminiKey ? { apiKey: geminiKey, model: values['gemini-model'] } : undefined;
|
|
96
|
+
const servers = [];
|
|
97
|
+
const sources = [];
|
|
98
|
+
if (values.config?.length) {
|
|
99
|
+
for (const file of values.config) {
|
|
100
|
+
const cfg = await readConfigFile(file, 'config');
|
|
101
|
+
if (!cfg) {
|
|
102
|
+
console.error(`cannot read ${file}`);
|
|
103
|
+
return 2;
|
|
104
|
+
}
|
|
105
|
+
servers.push(...cfg.servers);
|
|
106
|
+
sources.push(file);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const url of values.url ?? []) {
|
|
110
|
+
servers.push({ name: url, source: 'command line', kind: 'remote', url, type: /\/sse\/?$/.test(url) ? 'sse' : 'streamable-http' });
|
|
111
|
+
}
|
|
112
|
+
for (const line of values.stdio ?? []) {
|
|
113
|
+
const [command, ...args] = line.trim().split(/\s+/);
|
|
114
|
+
servers.push({ name: line, source: 'command line', kind: 'stdio', command, args });
|
|
115
|
+
}
|
|
116
|
+
if (!values.config?.length && !values.url?.length && !values.stdio?.length) {
|
|
117
|
+
const found = await discoverConfigs();
|
|
118
|
+
for (const cfg of found) {
|
|
119
|
+
servers.push(...cfg.servers);
|
|
120
|
+
sources.push(`${cfg.path} (${cfg.client}, ${cfg.servers.length})`);
|
|
121
|
+
}
|
|
122
|
+
if (!found.length) {
|
|
123
|
+
console.error('No MCP config found. Pass --config, --url or --stdio. See --help.');
|
|
124
|
+
return 2;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Credentials for servers that need auth. The file is optional; a missing default file means none.
|
|
128
|
+
const credentialsFile = values.credentials ?? defaultCredentialsPath();
|
|
129
|
+
let store = null;
|
|
130
|
+
try {
|
|
131
|
+
store = await loadCredentials(credentialsFile);
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
console.error(`cannot read ${credentialsFile}: ${e instanceof Error ? e.message : String(e)}`);
|
|
135
|
+
return 2;
|
|
136
|
+
}
|
|
137
|
+
const providers = [];
|
|
138
|
+
const rows = [];
|
|
139
|
+
const running = [];
|
|
140
|
+
for (const s of servers) {
|
|
141
|
+
const row = { name: s.name, source: s.source, kind: s.kind, target: s.kind === 'remote' ? s.url : [s.command, ...(s.args ?? [])].join(' ') };
|
|
142
|
+
rows.push(row);
|
|
143
|
+
if (s.disabled) {
|
|
144
|
+
row.skipped = 'disabled in config';
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (s.kind === 'stdio' && values['no-stdio']) {
|
|
148
|
+
row.skipped = 'local server, --no-stdio';
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
let headers = s.headers;
|
|
152
|
+
let authProvider;
|
|
153
|
+
if (s.kind === 'remote' && store) {
|
|
154
|
+
const creds = credentialsFor(store, s.url);
|
|
155
|
+
if (creds) {
|
|
156
|
+
// Headers from the config win; the stored ones fill in what the config leaves out.
|
|
157
|
+
if (creds.headers)
|
|
158
|
+
headers = { ...creds.headers, ...(s.headers ?? {}) };
|
|
159
|
+
authProvider = creds.authProvider;
|
|
160
|
+
if (authProvider)
|
|
161
|
+
providers.push(authProvider);
|
|
162
|
+
row.withCredentials = true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const job = s.kind === 'remote'
|
|
166
|
+
? probeRemote({ url: s.url, type: s.type ?? 'streamable-http' }, { timeoutMs, headers, authProvider, claude: claudeFor(s.name), gemini })
|
|
167
|
+
: probeStdio({ command: s.command, args: s.args, env: s.env, cwd: s.cwd }, { timeoutMs, claude: claudeFor(s.name), gemini });
|
|
168
|
+
running.push(job.then((r) => void (row.result = r)));
|
|
169
|
+
}
|
|
170
|
+
await Promise.all(running);
|
|
171
|
+
// A refreshed token goes back to the file.
|
|
172
|
+
if (store && providers.some((p) => p.changed))
|
|
173
|
+
await saveCredentials(store, credentialsFile).catch((e) => console.error(`cannot write ${credentialsFile}: ${e instanceof Error ? e.message : String(e)}`));
|
|
174
|
+
const measured = rows.filter((r) => r.result?.status === 'ok');
|
|
175
|
+
/** The number a row is ranked and budgeted by: the exact Claude count with --claude, else the Gemini count with --gemini, else the estimate. */
|
|
176
|
+
const basis = values.claude ? 'claude' : values.gemini ? 'gemini' : 'estimate';
|
|
177
|
+
const exact = basis !== 'estimate';
|
|
178
|
+
const cost = (r) => (basis === 'claude' ? r.result?.claude?.tokens : basis === 'gemini' ? r.result?.gemini?.tokens : r.result?.toolsTokens);
|
|
179
|
+
const total = measured.reduce((a, r) => a + (cost(r) ?? 0), 0);
|
|
180
|
+
const totalEstimate = measured.reduce((a, r) => a + (r.result?.toolsTokens ?? 0), 0);
|
|
181
|
+
const totalTools = measured.reduce((a, r) => a + (r.result?.toolCount ?? 0), 0);
|
|
182
|
+
const model = basis === 'gemini'
|
|
183
|
+
? (measured.find((r) => r.result?.gemini?.model)?.result?.gemini?.model ?? values['gemini-model'] ?? 'gemini-3.8-flash')
|
|
184
|
+
: (measured.find((r) => r.result?.claude?.model)?.result?.claude?.model ?? values.model ?? 'claude-opus-5-5');
|
|
185
|
+
const geminiModel = measured.find((r) => r.result?.gemini?.model)?.result?.gemini?.model ?? values['gemini-model'] ?? 'gemini-3.8-flash';
|
|
186
|
+
rows.sort((a, b) => (cost(b) ?? -1) - (cost(a) ?? -1));
|
|
187
|
+
if (values.json) {
|
|
188
|
+
console.log(JSON.stringify({
|
|
189
|
+
version: VERSION,
|
|
190
|
+
tokenizer: TOKENIZER,
|
|
191
|
+
exact: basis === 'claude' ? { endpoint: 'count_tokens', model, provider: 'anthropic' } : basis === 'gemini' ? { endpoint: 'countTokens', model, provider: 'google' } : null,
|
|
192
|
+
window,
|
|
193
|
+
sources,
|
|
194
|
+
total: { servers: measured.length, tools: totalTools, tokens: total, tokensEstimate: totalEstimate, shareOfWindow: total / window },
|
|
195
|
+
servers: rows.map((r) => ({
|
|
196
|
+
name: r.name,
|
|
197
|
+
source: r.source,
|
|
198
|
+
kind: r.kind,
|
|
199
|
+
target: r.target,
|
|
200
|
+
skipped: r.skipped,
|
|
201
|
+
withCredentials: r.withCredentials || undefined,
|
|
202
|
+
status: r.result?.status,
|
|
203
|
+
error: r.result?.error,
|
|
204
|
+
toolCount: r.result?.toolCount,
|
|
205
|
+
/** The number the row is ranked and budgeted by, on the basis above. */
|
|
206
|
+
tokens: cost(r),
|
|
207
|
+
toolsTokens: r.result?.toolsTokens,
|
|
208
|
+
claudeTokens: r.result?.claude?.tokens,
|
|
209
|
+
claudeTokensClaudeCode: r.result?.claude?.tokensClaudeCode,
|
|
210
|
+
claudeError: r.result?.claude?.error,
|
|
211
|
+
geminiTokens: r.result?.gemini?.tokens,
|
|
212
|
+
geminiModel: r.result?.gemini?.model,
|
|
213
|
+
geminiError: r.result?.gemini?.error,
|
|
214
|
+
shareOfWindow: cost(r) != null ? cost(r) / window : undefined,
|
|
215
|
+
initializeMs: r.result?.latencyMs.initialize,
|
|
216
|
+
toolsListMs: r.result?.latencyMs.toolsList,
|
|
217
|
+
protocolVersion: r.result?.protocolVersion,
|
|
218
|
+
tools: topTools > 0 ? r.result?.tools?.slice().sort((a, b) => b.tokens - a.tokens).slice(0, topTools) : undefined
|
|
219
|
+
}))
|
|
220
|
+
}, null, 2));
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
console.log(render(rows, { window, total, totalTools, topTools, sources, basis, model, geminiModel, withGemini: Boolean(gemini), cost }));
|
|
224
|
+
}
|
|
225
|
+
if (budget !== undefined && total > budget) {
|
|
226
|
+
console.error(`\nOver budget: ${fmt(total)} tokens, the budget is ${fmt(budget)}.`);
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
231
|
+
function fmt(n) {
|
|
232
|
+
if (n === undefined || n === null)
|
|
233
|
+
return '';
|
|
234
|
+
return n.toLocaleString('en-US').replace(/,/g, ' ');
|
|
235
|
+
}
|
|
236
|
+
function pct(n, window) {
|
|
237
|
+
const p = (n / window) * 100;
|
|
238
|
+
return p < 0.1 && n > 0 ? '<0.1 %' : `${p.toFixed(1)} %`;
|
|
239
|
+
}
|
|
240
|
+
function shortSource(source) {
|
|
241
|
+
return source.replace(homedir(), '~').replace(/ \(project .*\)$/, ' (project)');
|
|
242
|
+
}
|
|
243
|
+
function statusText(r) {
|
|
244
|
+
if (r.skipped)
|
|
245
|
+
return `skipped: ${r.skipped}`;
|
|
246
|
+
const s = r.result?.status;
|
|
247
|
+
if (!s)
|
|
248
|
+
return '';
|
|
249
|
+
if (s === 'ok')
|
|
250
|
+
return r.withCredentials ? 'alive (credentials)' : 'alive';
|
|
251
|
+
if (s === 'auth')
|
|
252
|
+
return `needs auth${r.result?.authScheme ? ` (${r.result.authScheme})` : ''}${r.withCredentials ? ', stored credentials refused' : ''}`;
|
|
253
|
+
if (s === 'payment')
|
|
254
|
+
return 'payment required';
|
|
255
|
+
if (s === 'newer_protocol')
|
|
256
|
+
return 'newer protocol than this SDK';
|
|
257
|
+
if (s === 'not_found')
|
|
258
|
+
return r.kind === 'stdio' ? 'command not found' : 'not found';
|
|
259
|
+
return s.replace('_', ' ');
|
|
260
|
+
}
|
|
261
|
+
function render(rows, o) {
|
|
262
|
+
const claude = o.basis === 'claude';
|
|
263
|
+
const lines = [];
|
|
264
|
+
lines.push(`mcpcost ${VERSION} · tool definitions your agent loads before the first prompt`);
|
|
265
|
+
if (o.sources.length)
|
|
266
|
+
lines.push(o.sources.map((s) => ` ${shortSource(s)}`).join('\n'));
|
|
267
|
+
lines.push('');
|
|
268
|
+
if (!rows.length) {
|
|
269
|
+
lines.push(' No servers in these configs.');
|
|
270
|
+
return lines.join('\n');
|
|
271
|
+
}
|
|
272
|
+
const table = [
|
|
273
|
+
[
|
|
274
|
+
'server', 'source', 'result', 'tools',
|
|
275
|
+
...(claude ? ['claude', 'claude code'] : []),
|
|
276
|
+
...(o.withGemini ? ['gemini'] : []),
|
|
277
|
+
o.basis === 'estimate' ? 'tokens' : 'o200k',
|
|
278
|
+
`of ${Math.round(o.window / 1000)}k`
|
|
279
|
+
]
|
|
280
|
+
];
|
|
281
|
+
const exactCell = (n, error) => (n != null ? fmt(n) : error ? 'refused' : '');
|
|
282
|
+
for (const r of rows) {
|
|
283
|
+
const ok = r.result?.status === 'ok';
|
|
284
|
+
const c = r.result?.claude;
|
|
285
|
+
const g = r.result?.gemini;
|
|
286
|
+
table.push([
|
|
287
|
+
r.name.length > 28 ? r.name.slice(0, 27) + '…' : r.name,
|
|
288
|
+
shortSource(r.source).replace(/^.*\//, ''),
|
|
289
|
+
statusText(r),
|
|
290
|
+
ok ? fmt(r.result?.toolCount) : '',
|
|
291
|
+
...(claude ? [ok ? exactCell(c?.tokens, c?.error) : '', ok ? fmt(c?.tokensClaudeCode) : ''] : []),
|
|
292
|
+
...(o.withGemini ? [ok ? exactCell(g?.tokens, g?.error) : ''] : []),
|
|
293
|
+
ok ? fmt(r.result?.toolsTokens) : '',
|
|
294
|
+
ok && o.cost(r) != null ? pct(o.cost(r), o.window) : ''
|
|
295
|
+
]);
|
|
296
|
+
}
|
|
297
|
+
const totalEstimate = rows.reduce((a, r) => a + (r.result?.status === 'ok' ? (r.result.toolsTokens ?? 0) : 0), 0);
|
|
298
|
+
const totalCc = rows.reduce((a, r) => a + (r.result?.claude?.tokensClaudeCode ?? 0), 0);
|
|
299
|
+
const totalClaude = rows.reduce((a, r) => a + (r.result?.claude?.tokens ?? 0), 0);
|
|
300
|
+
const totalGemini = rows.reduce((a, r) => a + (r.result?.gemini?.tokens ?? 0), 0);
|
|
301
|
+
table.push([
|
|
302
|
+
'total', '', `${rows.filter((r) => r.result?.status === 'ok').length} of ${rows.length} measured`, fmt(o.totalTools),
|
|
303
|
+
...(claude ? [fmt(totalClaude), fmt(totalCc)] : []),
|
|
304
|
+
...(o.withGemini ? [fmt(totalGemini)] : []),
|
|
305
|
+
fmt(totalEstimate), pct(o.total, o.window)
|
|
306
|
+
]);
|
|
307
|
+
const widths = table[0].map((_, i) => Math.max(...table.map((row) => row[i].length)));
|
|
308
|
+
const right = new Set(table[0].map((_, i) => i).filter((i) => i >= 3));
|
|
309
|
+
const line = (row) => ' ' + row.map((cell, i) => (right.has(i) ? cell.padStart(widths[i]) : cell.padEnd(widths[i]))).join(' ').trimEnd();
|
|
310
|
+
lines.push(line(table[0]));
|
|
311
|
+
lines.push(' ' + widths.map((w) => '─'.repeat(w)).join(' '));
|
|
312
|
+
for (const row of table.slice(1, -1))
|
|
313
|
+
lines.push(line(row));
|
|
314
|
+
lines.push(' ' + widths.map((w) => '─'.repeat(w)).join(' '));
|
|
315
|
+
lines.push(line(table[table.length - 1]));
|
|
316
|
+
const failed = rows.filter((r) => r.result && r.result.status !== 'ok' && r.result.error);
|
|
317
|
+
if (failed.length) {
|
|
318
|
+
lines.push('');
|
|
319
|
+
for (const r of failed)
|
|
320
|
+
lines.push(` ${r.name}: ${r.result.error.split('\n')[0].slice(0, 160)}`);
|
|
321
|
+
}
|
|
322
|
+
if (o.topTools > 0) {
|
|
323
|
+
for (const r of rows) {
|
|
324
|
+
if (!r.result?.tools?.length)
|
|
325
|
+
continue;
|
|
326
|
+
lines.push('', ` ${r.name}: most expensive tools`);
|
|
327
|
+
for (const t of [...r.result.tools].sort((a, b) => b.tokens - a.tokens).slice(0, o.topTools)) {
|
|
328
|
+
lines.push(` ${fmt(t.tokens).padStart(7)} ${t.name}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (o.basis !== 'estimate') {
|
|
333
|
+
lines.push('');
|
|
334
|
+
if (claude)
|
|
335
|
+
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.`);
|
|
336
|
+
if (o.withGemini)
|
|
337
|
+
lines.push(` gemini: exact count from the Gemini countTokens endpoint for ${o.geminiModel}.`);
|
|
338
|
+
lines.push(` o200k: the estimate without a key, ${TOKENIZER} over name, description and input schema. "refused": the endpoint did not accept the schema.`);
|
|
339
|
+
const refused = rows.filter((r) => r.result?.claude?.error || r.result?.gemini?.error);
|
|
340
|
+
for (const r of refused)
|
|
341
|
+
lines.push(` ${r.name}: ${(r.result.claude?.error ?? r.result.gemini?.error).slice(0, 160)}`);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
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.`);
|
|
345
|
+
}
|
|
346
|
+
return lines.join('\n');
|
|
347
|
+
}
|
|
348
|
+
function invokedDirectly() {
|
|
349
|
+
if (!process.argv[1])
|
|
350
|
+
return false;
|
|
351
|
+
try {
|
|
352
|
+
// The bin is a symlink in node_modules/.bin, so compare real paths.
|
|
353
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (invokedDirectly()) {
|
|
360
|
+
main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
|
|
361
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
362
|
+
process.exit(2);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
@@ -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[]>;
|
package/dist/configs.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
/** Expands ${VAR} and ${VAR:-default} the way Claude Code does in .mcp.json. */
|
|
5
|
+
export function expand(value, env) {
|
|
6
|
+
return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (whole, name, fallback) => {
|
|
7
|
+
const v = env[name];
|
|
8
|
+
if (v !== undefined)
|
|
9
|
+
return v;
|
|
10
|
+
if (fallback !== undefined)
|
|
11
|
+
return fallback;
|
|
12
|
+
return whole;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
function expandRecord(record, env) {
|
|
16
|
+
if (!record)
|
|
17
|
+
return undefined;
|
|
18
|
+
const out = {};
|
|
19
|
+
for (const [k, v] of Object.entries(record))
|
|
20
|
+
if (typeof v === 'string')
|
|
21
|
+
out[k] = expand(v, env);
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
function remoteType(raw, url) {
|
|
25
|
+
const t = String(raw.type ?? raw.transport ?? '').toLowerCase();
|
|
26
|
+
if (t === 'sse')
|
|
27
|
+
return 'sse';
|
|
28
|
+
if (t === 'http' || t === 'streamable-http' || t === 'streamablehttp' || t === 'streamable_http')
|
|
29
|
+
return 'streamable-http';
|
|
30
|
+
return /\/sse\/?$/.test(url) ? 'sse' : 'streamable-http';
|
|
31
|
+
}
|
|
32
|
+
/** Normalises one `{ name: {...} }` map of servers. Accepts the shapes Claude, Cursor, Windsurf and VS Code write. */
|
|
33
|
+
export function normalizeServers(map, source, env = process.env) {
|
|
34
|
+
if (!map || typeof map !== 'object')
|
|
35
|
+
return [];
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const [name, value] of Object.entries(map)) {
|
|
38
|
+
if (!value || typeof value !== 'object')
|
|
39
|
+
continue;
|
|
40
|
+
const raw = value;
|
|
41
|
+
const disabled = raw.disabled === true || raw.enabled === false;
|
|
42
|
+
const url = typeof raw.url === 'string' ? expand(raw.url, env) : typeof raw.serverUrl === 'string' ? expand(raw.serverUrl, env) : undefined;
|
|
43
|
+
if (url && String(raw.type ?? '').toLowerCase() !== 'stdio') {
|
|
44
|
+
out.push({ name, source, kind: 'remote', url, type: remoteType(raw, url), headers: expandRecord(raw.headers, env), disabled });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (typeof raw.command === 'string') {
|
|
48
|
+
const args = Array.isArray(raw.args) ? raw.args.filter((a) => typeof a === 'string').map((a) => expand(a, env)) : [];
|
|
49
|
+
out.push({
|
|
50
|
+
name,
|
|
51
|
+
source,
|
|
52
|
+
kind: 'stdio',
|
|
53
|
+
command: expand(raw.command, env),
|
|
54
|
+
args,
|
|
55
|
+
env: expandRecord(raw.env, env),
|
|
56
|
+
cwd: typeof raw.cwd === 'string' ? expand(raw.cwd, env) : undefined,
|
|
57
|
+
disabled
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
async function readJson(file) {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Parses one config file of a known client. Returns null when the file is missing or not JSON. */
|
|
72
|
+
export async function readConfigFile(file, client, options = {}) {
|
|
73
|
+
const env = options.env ?? process.env;
|
|
74
|
+
const json = await readJson(file);
|
|
75
|
+
if (!json)
|
|
76
|
+
return null;
|
|
77
|
+
const servers = [];
|
|
78
|
+
if (client === 'Claude Code (user)') {
|
|
79
|
+
servers.push(...normalizeServers(json.mcpServers, `${file} (user)`, env));
|
|
80
|
+
const projects = (json.projects ?? {});
|
|
81
|
+
const cwd = options.cwd ?? process.cwd();
|
|
82
|
+
for (const [projectPath, project] of Object.entries(projects)) {
|
|
83
|
+
if (projectPath === cwd || cwd.startsWith(projectPath.replace(/\/?$/, '/'))) {
|
|
84
|
+
servers.push(...normalizeServers(project.mcpServers, `${file} (project ${projectPath})`, env));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else if (json.servers && typeof json.servers === 'object') {
|
|
89
|
+
servers.push(...normalizeServers(json.servers, file, env));
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
servers.push(...normalizeServers(json.mcpServers, file, env));
|
|
93
|
+
}
|
|
94
|
+
return { path: file, client, servers };
|
|
95
|
+
}
|
|
96
|
+
/** The places the common clients keep their MCP config. */
|
|
97
|
+
export function knownConfigPaths(options = {}) {
|
|
98
|
+
const cwd = options.cwd ?? process.cwd();
|
|
99
|
+
const home = options.home ?? homedir();
|
|
100
|
+
const platform = options.platform ?? process.platform;
|
|
101
|
+
const env = options.env ?? process.env;
|
|
102
|
+
const desktop = platform === 'darwin'
|
|
103
|
+
? path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
|
|
104
|
+
: platform === 'win32'
|
|
105
|
+
? path.join(env.APPDATA ?? path.join(home, 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json')
|
|
106
|
+
: path.join(home, '.config', 'Claude', 'claude_desktop_config.json');
|
|
107
|
+
return [
|
|
108
|
+
{ path: path.join(cwd, '.mcp.json'), client: 'Claude Code (project)' },
|
|
109
|
+
{ path: path.join(home, '.claude.json'), client: 'Claude Code (user)' },
|
|
110
|
+
{ path: desktop, client: 'Claude Desktop' },
|
|
111
|
+
{ path: path.join(cwd, '.cursor', 'mcp.json'), client: 'Cursor (project)' },
|
|
112
|
+
{ path: path.join(home, '.cursor', 'mcp.json'), client: 'Cursor (user)' },
|
|
113
|
+
{ path: path.join(cwd, '.vscode', 'mcp.json'), client: 'VS Code (workspace)' },
|
|
114
|
+
{ path: path.join(home, '.codeium', 'windsurf', 'mcp_config.json'), client: 'Windsurf' },
|
|
115
|
+
{ path: path.join(home, '.gemini', 'settings.json'), client: 'Gemini CLI' }
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
/** Reads every known config file that exists. */
|
|
119
|
+
export async function discoverConfigs(options = {}) {
|
|
120
|
+
const found = [];
|
|
121
|
+
for (const { path: file, client } of knownConfigPaths(options)) {
|
|
122
|
+
const cfg = await readConfigFile(file, client, options);
|
|
123
|
+
if (cfg)
|
|
124
|
+
found.push(cfg);
|
|
125
|
+
}
|
|
126
|
+
return found;
|
|
127
|
+
}
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
2
|
+
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
|
|
3
|
+
import type { ProbeResult, ProbeStatus, RemoteTransport } from './types.js';
|
|
4
|
+
export declare const VERSION = "0.3.0";
|
|
5
|
+
export declare const USER_AGENT = "mcpcost/0.3.0 (mcpcost; +https://github.com/ondraulehla/mcpcost)";
|
|
6
|
+
export interface RawTool {
|
|
7
|
+
name: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
inputSchema?: unknown;
|
|
10
|
+
}
|
|
11
|
+
export interface CoreOptions {
|
|
12
|
+
/** Timeout for the whole probe and for each request. Default 15 000 ms. */
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
/** Extra request headers, for example an Authorization header from your config. */
|
|
15
|
+
headers?: Record<string, string>;
|
|
16
|
+
/** An OAuth client with stored tokens; the transport sends them and refreshes them. See credentials.ts. */
|
|
17
|
+
authProvider?: OAuthClientProvider;
|
|
18
|
+
fetch?: typeof fetch;
|
|
19
|
+
}
|
|
20
|
+
export interface RawProbe {
|
|
21
|
+
result: ProbeResult;
|
|
22
|
+
tools: RawTool[];
|
|
23
|
+
}
|
|
24
|
+
interface Observed {
|
|
25
|
+
status: number;
|
|
26
|
+
headers: Headers;
|
|
27
|
+
url: string;
|
|
28
|
+
}
|
|
29
|
+
export declare function emptyResult(url: string, transport: ProbeResult['transport']): ProbeResult;
|
|
30
|
+
/** Connects to a remote MCP server and lists its tools. No token counting. */
|
|
31
|
+
export declare function probeRemoteRaw(remote: {
|
|
32
|
+
url: string;
|
|
33
|
+
type: RemoteTransport;
|
|
34
|
+
}, options?: CoreOptions): Promise<RawProbe>;
|
|
35
|
+
export declare function measure(transport: Transport, result: ProbeResult, timeoutMs: number): Promise<RawTool[]>;
|
|
36
|
+
export declare function classify(message: string | undefined, first: Observed | undefined): ProbeStatus;
|
|
37
|
+
export {};
|