mcpcost 0.2.0 → 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/README.md CHANGED
@@ -11,7 +11,7 @@ Every MCP server you configure hands your agent its tool definitions at the star
11
11
  ```
12
12
  $ npx mcpcost --config .mcp.json --tools 3
13
13
 
14
- mcpcost 0.2.0 · tool definitions your agent loads before the first prompt
14
+ mcpcost 0.3.0 · tool definitions your agent loads before the first prompt
15
15
  .mcp.json
16
16
 
17
17
  server source result tools tokens of 200k
@@ -0,0 +1 @@
1
+ export declare function authCommand(argv: string[]): Promise<number>;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * `mcpcost auth`: the consent step for a server that needs auth, once, in the
3
+ * user's browser. The result lands in the credentials file; every later
4
+ * measurement of that URL uses it and refreshes the token on its own.
5
+ */
6
+ import { createServer } from 'node:http';
7
+ import { spawn } from 'node:child_process';
8
+ import { parseArgs } from 'node:util';
9
+ import { auth } from '@modelcontextprotocol/sdk/client/auth.js';
10
+ import { credentialKey, createProvider, credentialsFor, defaultCredentialsPath, describeEntry, loadCredentials, saveCredentials } from './credentials.js';
11
+ import { probeRemote } from './probe.js';
12
+ const HELP = `mcpcost auth: store credentials for a server that needs auth
13
+
14
+ Usage
15
+ mcpcost auth <url> OAuth: register, open the browser for the consent, store the tokens
16
+ mcpcost auth <url> --header "Name: value" store a static header instead (repeatable)
17
+ mcpcost auth --list what the credentials file holds, without the secrets
18
+ mcpcost auth <url> --remove forget the entry for this URL
19
+
20
+ Options
21
+ --credentials <file> the credentials file (default ~/.mcpcost/credentials.json, or $MCPCOST_CREDENTIALS)
22
+ --scope <scope> scope to ask for in the OAuth flow
23
+ --no-open print the authorization URL, do not open the browser
24
+ --timeout <ms> how long to wait for the consent (default 300000)
25
+
26
+ After the consent the server is measured once with the new credentials, so you see that it works.
27
+ The daily board uses the same file through its MCPCOST credentials table; see the README.`;
28
+ function openBrowser(url) {
29
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url.replace(/&/g, '^&')]] : ['xdg-open', [url]];
30
+ try {
31
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
32
+ child.on('error', () => undefined);
33
+ child.unref();
34
+ }
35
+ catch {
36
+ // The URL is printed too.
37
+ }
38
+ }
39
+ /** Waits for one GET /callback?code=…&state=… on a local port. */
40
+ function callbackServer(expectedState, timeoutMs) {
41
+ return new Promise((resolveServer, rejectServer) => {
42
+ let resolveCode = () => undefined;
43
+ let rejectCode = () => undefined;
44
+ const code = new Promise((res, rej) => {
45
+ resolveCode = res;
46
+ rejectCode = rej;
47
+ });
48
+ const server = createServer((req, res) => {
49
+ const u = new URL(req.url ?? '/', 'http://127.0.0.1');
50
+ if (u.pathname !== '/callback') {
51
+ res.writeHead(404).end();
52
+ return;
53
+ }
54
+ const error = u.searchParams.get('error');
55
+ const received = u.searchParams.get('code');
56
+ const state = u.searchParams.get('state');
57
+ res.setHeader('content-type', 'text/html; charset=utf-8');
58
+ if (error || !received) {
59
+ res.writeHead(400).end(`<p>mcpcost: the authorization failed: ${error ?? 'no code'}. You can close this tab.</p>`);
60
+ rejectCode(new Error(`authorization failed: ${error ?? 'no code in the callback'} ${u.searchParams.get('error_description') ?? ''}`.trim()));
61
+ return;
62
+ }
63
+ if (expectedState() && state !== expectedState()) {
64
+ res.writeHead(400).end('<p>mcpcost: the state did not match. You can close this tab.</p>');
65
+ rejectCode(new Error('the state in the callback did not match'));
66
+ return;
67
+ }
68
+ res.writeHead(200).end('<p>mcpcost: authorized. You can close this tab and go back to the terminal.</p>');
69
+ resolveCode(received);
70
+ });
71
+ const timer = setTimeout(() => rejectCode(new Error(`no callback within ${Math.round(timeoutMs / 1000)} s`)), timeoutMs);
72
+ timer.unref();
73
+ server.on('error', rejectServer);
74
+ server.listen(0, '127.0.0.1', () => {
75
+ const port = server.address().port;
76
+ resolveServer({ redirectUrl: `http://127.0.0.1:${port}/callback`, code, close: () => { clearTimeout(timer); server.close(); } });
77
+ });
78
+ });
79
+ }
80
+ export async function authCommand(argv) {
81
+ const { values, positionals } = parseArgs({
82
+ args: argv,
83
+ options: {
84
+ header: { type: 'string', multiple: true },
85
+ credentials: { type: 'string' },
86
+ list: { type: 'boolean', default: false },
87
+ remove: { type: 'boolean', default: false },
88
+ scope: { type: 'string' },
89
+ 'no-open': { type: 'boolean', default: false },
90
+ timeout: { type: 'string', default: '300000' },
91
+ help: { type: 'boolean', default: false }
92
+ },
93
+ allowPositionals: true
94
+ });
95
+ if (values.help) {
96
+ console.log(HELP);
97
+ return 0;
98
+ }
99
+ const file = values.credentials ?? defaultCredentialsPath();
100
+ const store = await loadCredentials(file);
101
+ if (values.list) {
102
+ const keys = Object.keys(store.servers);
103
+ if (!keys.length)
104
+ console.log(`${file}: no credentials stored`);
105
+ for (const k of keys)
106
+ console.log(`${k}\n ${describeEntry(store.servers[k])}`);
107
+ return 0;
108
+ }
109
+ const raw = positionals[0];
110
+ if (!raw) {
111
+ console.error(HELP);
112
+ return 2;
113
+ }
114
+ let key;
115
+ try {
116
+ key = credentialKey(raw);
117
+ }
118
+ catch {
119
+ console.error(`${raw} is not a URL`);
120
+ return 2;
121
+ }
122
+ if (values.remove) {
123
+ if (store.servers[key]) {
124
+ delete store.servers[key];
125
+ await saveCredentials(store, file);
126
+ console.log(`${key}: removed from ${file}`);
127
+ }
128
+ else
129
+ console.log(`${key}: nothing stored`);
130
+ return 0;
131
+ }
132
+ if (values.header?.length) {
133
+ const entry = (store.servers[key] ??= {});
134
+ entry.headers ??= {};
135
+ for (const h of values.header) {
136
+ const i = h.indexOf(':');
137
+ if (i <= 0) {
138
+ console.error(`--header wants "Name: value", got ${JSON.stringify(h)}`);
139
+ return 2;
140
+ }
141
+ entry.headers[h.slice(0, i).trim()] = h.slice(i + 1).trim();
142
+ }
143
+ await saveCredentials(store, file);
144
+ console.log(`${key}: ${describeEntry(entry)} stored in ${file}`);
145
+ return verify(store, key);
146
+ }
147
+ // The OAuth flow: discovery, registration, the consent in the browser, the token.
148
+ const timeoutMs = Number(values.timeout) || 300_000;
149
+ const cb = await callbackServer(() => provider.lastState, timeoutMs);
150
+ const provider = createProvider(store, key, {
151
+ redirectUrl: cb.redirectUrl,
152
+ scope: values.scope,
153
+ onRedirect: (url) => {
154
+ console.log(`\nOpen this URL and allow the access:\n ${url}\n`);
155
+ if (!values['no-open'])
156
+ openBrowser(url.toString());
157
+ console.log('Waiting for the consent…');
158
+ }
159
+ });
160
+ try {
161
+ const first = await auth(provider, { serverUrl: key });
162
+ if (first === 'REDIRECT') {
163
+ const code = await cb.code;
164
+ const second = await auth(provider, { serverUrl: key, authorizationCode: code });
165
+ if (second !== 'AUTHORIZED')
166
+ throw new Error(`unexpected result ${second}`);
167
+ }
168
+ await saveCredentials(store, file);
169
+ console.log(`${key}: ${describeEntry(store.servers[key])} stored in ${file}`);
170
+ }
171
+ catch (err) {
172
+ const message = err instanceof Error ? err.message : String(err);
173
+ if (provider.changed)
174
+ await saveCredentials(store, file);
175
+ console.error(`${key}: the OAuth flow failed: ${message}`);
176
+ console.error('If the server takes an API key instead, store it with: mcpcost auth <url> --header "Authorization: Bearer …"');
177
+ return 1;
178
+ }
179
+ finally {
180
+ cb.close();
181
+ }
182
+ return verify(store, key);
183
+ }
184
+ /** One measurement with the stored credentials, so the user sees the result. */
185
+ async function verify(store, key) {
186
+ const creds = credentialsFor(store, key);
187
+ const type = /\/sse\/?$/.test(new URL(key).pathname) ? 'sse' : 'streamable-http';
188
+ const r = await probeRemote({ url: key, type }, { timeoutMs: 20_000, headers: creds?.headers, authProvider: creds?.authProvider, includeTools: false });
189
+ if (r.status === 'ok')
190
+ console.log(`measured: alive, ${r.toolCount} tools, ${r.toolsTokens?.toLocaleString('en-US')} tokens (o200k estimate)`);
191
+ else
192
+ console.log(`measured: ${r.status}${r.httpStatus ? ` (HTTP ${r.httpStatus})` : ''}${r.error ? `: ${r.error.split('\n')[0].slice(0, 160)}` : ''}`);
193
+ return r.status === 'ok' ? 0 : 1;
194
+ }
package/dist/cli.js CHANGED
@@ -7,11 +7,13 @@ import { discoverConfigs, readConfigFile } from './configs.js';
7
7
  import { probeRemote, VERSION } from './probe.js';
8
8
  import { probeStdio } from './stdio.js';
9
9
  import { TOKENIZER } from './tokens.js';
10
+ import { loadCredentials, saveCredentials, credentialsFor, defaultCredentialsPath } from './credentials.js';
10
11
  const HELP = `mcpcost ${VERSION}: how many tokens do your MCP servers cost before the first prompt?
11
12
 
12
13
  Usage
13
14
  mcpcost [options] measure every server in the configs it can find
14
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)
15
17
  mcpcost --config <file> measure one config file (repeatable)
16
18
  mcpcost --url <url> measure one remote server (repeatable)
17
19
  mcpcost --stdio "<command args>" measure one local server (repeatable)
@@ -22,14 +24,18 @@ Options
22
24
  --tools <n> list the n most expensive tools of each server (default 0)
23
25
  --claude exact counts from the Anthropic count_tokens endpoint (free, needs ANTHROPIC_API_KEY)
24
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)
25
29
  --timeout <ms> per server (default 15000)
26
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)
27
32
  --json machine-readable output
28
33
  --help, --version
29
34
 
30
35
  Config files it looks for: .mcp.json, ~/.claude.json, Claude Desktop, Cursor, VS Code, Windsurf, Gemini CLI.
31
36
  Without --claude, tokens are an estimate: ${TOKENIZER} over name, description and input schema of each tool.
32
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.
33
39
  The same probe runs the public board at https://mcpcost.ulehla.dev`;
34
40
  export async function main(argv) {
35
41
  if (argv[0] === 'mcp') {
@@ -37,6 +43,10 @@ export async function main(argv) {
37
43
  await serveStdio();
38
44
  return new Promise(() => undefined);
39
45
  }
46
+ if (argv[0] === 'auth') {
47
+ const { authCommand } = await import('./auth-cli.js');
48
+ return authCommand(argv.slice(1));
49
+ }
40
50
  const { values } = parseArgs({
41
51
  args: argv,
42
52
  options: {
@@ -48,8 +58,11 @@ export async function main(argv) {
48
58
  tools: { type: 'string', default: '0' },
49
59
  timeout: { type: 'string', default: '15000' },
50
60
  'no-stdio': { type: 'boolean', default: false },
61
+ credentials: { type: 'string' },
51
62
  claude: { type: 'boolean', default: false },
52
63
  model: { type: 'string' },
64
+ gemini: { type: 'boolean', default: false },
65
+ 'gemini-model': { type: 'string' },
53
66
  json: { type: 'boolean', default: false },
54
67
  help: { type: 'boolean', default: false },
55
68
  version: { type: 'boolean', default: false }
@@ -74,6 +87,12 @@ export async function main(argv) {
74
87
  return 2;
75
88
  }
76
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;
77
96
  const servers = [];
78
97
  const sources = [];
79
98
  if (values.config?.length) {
@@ -105,6 +124,17 @@ export async function main(argv) {
105
124
  return 2;
106
125
  }
107
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 = [];
108
138
  const rows = [];
109
139
  const running = [];
110
140
  for (const s of servers) {
@@ -118,26 +148,47 @@ export async function main(argv) {
118
148
  row.skipped = 'local server, --no-stdio';
119
149
  continue;
120
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
+ }
121
165
  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) });
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 });
124
168
  running.push(job.then((r) => void (row.result = r)));
125
169
  }
126
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)}`));
127
174
  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);
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);
131
179
  const total = measured.reduce((a, r) => a + (cost(r) ?? 0), 0);
132
180
  const totalEstimate = measured.reduce((a, r) => a + (r.result?.toolsTokens ?? 0), 0);
133
181
  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';
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';
135
186
  rows.sort((a, b) => (cost(b) ?? -1) - (cost(a) ?? -1));
136
187
  if (values.json) {
137
188
  console.log(JSON.stringify({
138
189
  version: VERSION,
139
190
  tokenizer: TOKENIZER,
140
- exact: exact ? { endpoint: 'count_tokens', model } : null,
191
+ exact: basis === 'claude' ? { endpoint: 'count_tokens', model, provider: 'anthropic' } : basis === 'gemini' ? { endpoint: 'countTokens', model, provider: 'google' } : null,
141
192
  window,
142
193
  sources,
143
194
  total: { servers: measured.length, tools: totalTools, tokens: total, tokensEstimate: totalEstimate, shareOfWindow: total / window },
@@ -147,13 +198,19 @@ export async function main(argv) {
147
198
  kind: r.kind,
148
199
  target: r.target,
149
200
  skipped: r.skipped,
201
+ withCredentials: r.withCredentials || undefined,
150
202
  status: r.result?.status,
151
203
  error: r.result?.error,
152
204
  toolCount: r.result?.toolCount,
205
+ /** The number the row is ranked and budgeted by, on the basis above. */
206
+ tokens: cost(r),
153
207
  toolsTokens: r.result?.toolsTokens,
154
208
  claudeTokens: r.result?.claude?.tokens,
155
209
  claudeTokensClaudeCode: r.result?.claude?.tokensClaudeCode,
156
210
  claudeError: r.result?.claude?.error,
211
+ geminiTokens: r.result?.gemini?.tokens,
212
+ geminiModel: r.result?.gemini?.model,
213
+ geminiError: r.result?.gemini?.error,
157
214
  shareOfWindow: cost(r) != null ? cost(r) / window : undefined,
158
215
  initializeMs: r.result?.latencyMs.initialize,
159
216
  toolsListMs: r.result?.latencyMs.toolsList,
@@ -163,7 +220,7 @@ export async function main(argv) {
163
220
  }, null, 2));
164
221
  }
165
222
  else {
166
- console.log(render(rows, { window, total, totalTools, topTools, sources, exact, model, cost }));
223
+ console.log(render(rows, { window, total, totalTools, topTools, sources, basis, model, geminiModel, withGemini: Boolean(gemini), cost }));
167
224
  }
168
225
  if (budget !== undefined && total > budget) {
169
226
  console.error(`\nOver budget: ${fmt(total)} tokens, the budget is ${fmt(budget)}.`);
@@ -190,9 +247,9 @@ function statusText(r) {
190
247
  if (!s)
191
248
  return '';
192
249
  if (s === 'ok')
193
- return 'alive';
250
+ return r.withCredentials ? 'alive (credentials)' : 'alive';
194
251
  if (s === 'auth')
195
- return `needs auth${r.result?.authScheme ? ` (${r.result.authScheme})` : ''}`;
252
+ return `needs auth${r.result?.authScheme ? ` (${r.result.authScheme})` : ''}${r.withCredentials ? ', stored credentials refused' : ''}`;
196
253
  if (s === 'payment')
197
254
  return 'payment required';
198
255
  if (s === 'newer_protocol')
@@ -202,6 +259,7 @@ function statusText(r) {
202
259
  return s.replace('_', ' ');
203
260
  }
204
261
  function render(rows, o) {
262
+ const claude = o.basis === 'claude';
205
263
  const lines = [];
206
264
  lines.push(`mcpcost ${VERSION} · tool definitions your agent loads before the first prompt`);
207
265
  if (o.sources.length)
@@ -212,28 +270,38 @@ function render(rows, o) {
212
270
  return lines.join('\n');
213
271
  }
214
272
  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`]
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
+ ]
218
280
  ];
281
+ const exactCell = (n, error) => (n != null ? fmt(n) : error ? 'refused' : '');
219
282
  for (const r of rows) {
220
283
  const ok = r.result?.status === 'ok';
221
284
  const c = r.result?.claude;
285
+ const g = r.result?.gemini;
222
286
  table.push([
223
287
  r.name.length > 28 ? r.name.slice(0, 27) + '…' : r.name,
224
288
  shortSource(r.source).replace(/^.*\//, ''),
225
289
  statusText(r),
226
290
  ok ? fmt(r.result?.toolCount) : '',
227
- ...(o.exact ? [ok ? (c?.tokens != null ? fmt(c.tokens) : c?.error ? 'refused' : '') : '', ok ? fmt(c?.tokensClaudeCode) : ''] : []),
291
+ ...(claude ? [ok ? exactCell(c?.tokens, c?.error) : '', ok ? fmt(c?.tokensClaudeCode) : ''] : []),
292
+ ...(o.withGemini ? [ok ? exactCell(g?.tokens, g?.error) : ''] : []),
228
293
  ok ? fmt(r.result?.toolsTokens) : '',
229
294
  ok && o.cost(r) != null ? pct(o.cost(r), o.window) : ''
230
295
  ]);
231
296
  }
232
297
  const totalEstimate = rows.reduce((a, r) => a + (r.result?.status === 'ok' ? (r.result.toolsTokens ?? 0) : 0), 0);
233
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);
234
301
  table.push([
235
302
  'total', '', `${rows.filter((r) => r.result?.status === 'ok').length} of ${rows.length} measured`, fmt(o.totalTools),
236
- ...(o.exact ? [fmt(o.total), fmt(totalCc)] : []),
303
+ ...(claude ? [fmt(totalClaude), fmt(totalCc)] : []),
304
+ ...(o.withGemini ? [fmt(totalGemini)] : []),
237
305
  fmt(totalEstimate), pct(o.total, o.window)
238
306
  ]);
239
307
  const widths = table[0].map((_, i) => Math.max(...table.map((row) => row[i].length)));
@@ -261,12 +329,16 @@ function render(rows, o) {
261
329
  }
262
330
  }
263
331
  }
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.`);
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}.`);
266
338
  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);
339
+ const refused = rows.filter((r) => r.result?.claude?.error || r.result?.gemini?.error);
268
340
  for (const r of refused)
269
- lines.push(` ${r.name}: ${r.result.claude.error.slice(0, 160)}`);
341
+ lines.push(` ${r.name}: ${(r.result.claude?.error ?? r.result.gemini?.error).slice(0, 160)}`);
270
342
  }
271
343
  else {
272
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.`);
package/dist/core.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
2
+ import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
2
3
  import type { ProbeResult, ProbeStatus, RemoteTransport } from './types.js';
3
- export declare const VERSION = "0.2.0";
4
- export declare const USER_AGENT = "mcpcost/0.2.0 (mcpcost; +https://github.com/ondraulehla/mcpcost)";
4
+ export declare const VERSION = "0.3.0";
5
+ export declare const USER_AGENT = "mcpcost/0.3.0 (mcpcost; +https://github.com/ondraulehla/mcpcost)";
5
6
  export interface RawTool {
6
7
  name: string;
7
8
  description?: string;
@@ -12,6 +13,8 @@ export interface CoreOptions {
12
13
  timeoutMs?: number;
13
14
  /** Extra request headers, for example an Authorization header from your config. */
14
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;
15
18
  fetch?: typeof fetch;
16
19
  }
17
20
  export interface RawProbe {
package/dist/core.js CHANGED
@@ -6,7 +6,7 @@
6
6
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
7
7
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
8
8
  import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
9
- export const VERSION = '0.2.0';
9
+ export const VERSION = '0.3.0';
10
10
  export const USER_AGENT = `mcpcost/${VERSION} (mcpcost; +https://github.com/ondraulehla/mcpcost)`;
11
11
  export function emptyResult(url, transport) {
12
12
  return { url, transport, probedAt: new Date().toISOString(), status: 'error', latencyMs: {} };
@@ -44,8 +44,8 @@ export async function probeRemoteRaw(remote, options = {}) {
44
44
  // Follow redirects across origins too: a server that moved should be measured where it lives.
45
45
  const requestInit = { headers: options.headers };
46
46
  const transport = remote.type === 'sse'
47
- ? new SSEClientTransport(url, { fetch: observingFetch, requestInit, redirectPolicy: 'follow' })
48
- : new StreamableHTTPClientTransport(url, { fetch: observingFetch, requestInit, redirectPolicy: 'follow' });
47
+ ? new SSEClientTransport(url, { fetch: observingFetch, requestInit, redirectPolicy: 'follow', authProvider: options.authProvider })
48
+ : new StreamableHTTPClientTransport(url, { fetch: observingFetch, requestInit, redirectPolicy: 'follow', authProvider: options.authProvider });
49
49
  const tools = await measure(transport, result, timeoutMs);
50
50
  if (result.status === 'ok') {
51
51
  result.httpStatus = first?.status;
@@ -0,0 +1,52 @@
1
+ import type { OAuthClientProvider, OAuthDiscoveryState } from '@modelcontextprotocol/sdk/client/auth.js';
2
+ import type { OAuthClientInformationMixed, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
3
+ export interface OAuthEntry {
4
+ clientInformation?: OAuthClientInformationMixed;
5
+ tokens?: OAuthTokens;
6
+ discovery?: OAuthDiscoveryState;
7
+ scope?: string;
8
+ /** When the user last went through the consent step. */
9
+ authorizedAt?: string;
10
+ }
11
+ export interface CredentialEntry {
12
+ /** Static request headers, for example an Authorization header with an API key. */
13
+ headers?: Record<string, string>;
14
+ /** The state of the MCP OAuth flow for this server. */
15
+ oauth?: OAuthEntry;
16
+ }
17
+ export interface CredentialStore {
18
+ version: 1;
19
+ servers: Record<string, CredentialEntry>;
20
+ }
21
+ export declare function defaultCredentialsPath(): string;
22
+ /** The key of a server in the store: its URL without fragment or trailing slash. */
23
+ export declare function credentialKey(url: string): string;
24
+ export declare function emptyStore(): CredentialStore;
25
+ export declare function loadCredentials(file?: string): Promise<CredentialStore>;
26
+ /** Writes the store with mode 0600; the directory is created when needed. */
27
+ export declare function saveCredentials(store: CredentialStore, file?: string): Promise<void>;
28
+ export declare function entryFor(store: CredentialStore, url: string): CredentialEntry | undefined;
29
+ /** What an entry holds, without a secret value in it. */
30
+ export declare function describeEntry(entry: CredentialEntry): string;
31
+ export interface ProviderOptions {
32
+ /** Where the authorization server sends the user back. Any URL for a non-interactive provider. */
33
+ redirectUrl?: string;
34
+ /** Called with the authorization URL when the user has to consent. Without it the provider refuses, so a batch run never hangs. */
35
+ onRedirect?: (url: URL) => void | Promise<void>;
36
+ /** Called after every write to the store. */
37
+ onChange?: () => void;
38
+ scope?: string;
39
+ }
40
+ export interface FileProvider extends OAuthClientProvider {
41
+ /** True once the provider wrote to the store. */
42
+ readonly changed: boolean;
43
+ /** The state parameter of the authorization request, for the callback check. */
44
+ readonly lastState: string | undefined;
45
+ }
46
+ /** An OAuthClientProvider over one entry of the store. */
47
+ export declare function createProvider(store: CredentialStore, url: string, options?: ProviderOptions): FileProvider;
48
+ /** What a probe needs for a URL: static headers, an OAuth provider, or nothing. */
49
+ export declare function credentialsFor(store: CredentialStore, url: string, options?: ProviderOptions): {
50
+ headers?: Record<string, string>;
51
+ authProvider?: FileProvider;
52
+ } | undefined;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Credentials for servers that need auth: static headers, or OAuth client
3
+ * information and tokens from the MCP authorization flow. One JSON file,
4
+ * keyed by server URL, written with mode 0600. The provider below implements
5
+ * the SDK's OAuthClientProvider over that file, so a transport refreshes
6
+ * tokens on its own and the refreshed tokens land back in the file.
7
+ *
8
+ * Node only: the Worker never imports this file.
9
+ */
10
+ import { existsSync } from 'node:fs';
11
+ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
12
+ import { randomBytes } from 'node:crypto';
13
+ import { homedir } from 'node:os';
14
+ import path from 'node:path';
15
+ export function defaultCredentialsPath() {
16
+ return process.env.MCPCOST_CREDENTIALS || path.join(homedir(), '.mcpcost', 'credentials.json');
17
+ }
18
+ /** The key of a server in the store: its URL without fragment or trailing slash. */
19
+ export function credentialKey(url) {
20
+ const u = new URL(url);
21
+ u.hash = '';
22
+ return u.toString().replace(/\/$/, '');
23
+ }
24
+ export function emptyStore() {
25
+ return { version: 1, servers: {} };
26
+ }
27
+ export async function loadCredentials(file = defaultCredentialsPath()) {
28
+ if (!existsSync(file))
29
+ return emptyStore();
30
+ const parsed = JSON.parse(await readFile(file, 'utf8'));
31
+ return { version: 1, servers: parsed.servers ?? {} };
32
+ }
33
+ /** Writes the store with mode 0600; the directory is created when needed. */
34
+ export async function saveCredentials(store, file = defaultCredentialsPath()) {
35
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
36
+ await writeFile(file, JSON.stringify(store, null, 2) + '\n', { mode: 0o600 });
37
+ if (process.platform !== 'win32')
38
+ await chmod(file, 0o600);
39
+ }
40
+ export function entryFor(store, url) {
41
+ try {
42
+ return store.servers[credentialKey(url)];
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ }
48
+ /** What an entry holds, without a secret value in it. */
49
+ export function describeEntry(entry) {
50
+ const parts = [];
51
+ if (entry.headers)
52
+ parts.push(`headers: ${Object.keys(entry.headers).join(', ')}`);
53
+ if (entry.oauth) {
54
+ const t = entry.oauth.tokens;
55
+ parts.push(t
56
+ ? `oauth: ${t.refresh_token ? 'access and refresh token' : 'access token only'}${entry.oauth.authorizedAt ? `, authorized ${entry.oauth.authorizedAt.slice(0, 10)}` : ''}`
57
+ : entry.oauth.clientInformation
58
+ ? 'oauth: client registered, no token yet'
59
+ : 'oauth: nothing yet');
60
+ }
61
+ return parts.join('; ') || 'empty';
62
+ }
63
+ /** An OAuthClientProvider over one entry of the store. */
64
+ export function createProvider(store, url, options = {}) {
65
+ const key = credentialKey(url);
66
+ const entry = (store.servers[key] ??= {});
67
+ const oauth = (entry.oauth ??= {});
68
+ if (options.scope)
69
+ oauth.scope = options.scope;
70
+ const redirectUrl = options.redirectUrl ?? 'http://127.0.0.1/callback';
71
+ let changed = false;
72
+ let verifier;
73
+ let state;
74
+ const touched = () => {
75
+ changed = true;
76
+ options.onChange?.();
77
+ };
78
+ return {
79
+ get changed() {
80
+ return changed;
81
+ },
82
+ get lastState() {
83
+ return state;
84
+ },
85
+ get redirectUrl() {
86
+ return redirectUrl;
87
+ },
88
+ get clientMetadata() {
89
+ return {
90
+ client_name: 'mcpcost',
91
+ client_uri: 'https://mcpcost.ulehla.dev',
92
+ redirect_uris: [redirectUrl],
93
+ grant_types: ['authorization_code', 'refresh_token'],
94
+ response_types: ['code'],
95
+ token_endpoint_auth_method: 'none',
96
+ ...(oauth.scope ? { scope: oauth.scope } : {})
97
+ };
98
+ },
99
+ state() {
100
+ state = randomBytes(16).toString('hex');
101
+ return state;
102
+ },
103
+ clientInformation() {
104
+ return oauth.clientInformation;
105
+ },
106
+ saveClientInformation(info) {
107
+ oauth.clientInformation = info;
108
+ touched();
109
+ },
110
+ tokens() {
111
+ return oauth.tokens;
112
+ },
113
+ saveTokens(tokens) {
114
+ oauth.tokens = tokens;
115
+ oauth.authorizedAt ??= new Date().toISOString();
116
+ touched();
117
+ },
118
+ async redirectToAuthorization(authorizationUrl) {
119
+ if (!options.onRedirect)
120
+ throw new Error(`${key} needs the consent step: run "mcpcost auth ${key}"`);
121
+ await options.onRedirect(authorizationUrl);
122
+ },
123
+ saveCodeVerifier(v) {
124
+ verifier = v;
125
+ },
126
+ codeVerifier() {
127
+ if (!verifier)
128
+ throw new Error('no code verifier: the authorization did not start in this process');
129
+ return verifier;
130
+ },
131
+ saveDiscoveryState(s) {
132
+ oauth.discovery = s;
133
+ touched();
134
+ },
135
+ discoveryState() {
136
+ return oauth.discovery;
137
+ },
138
+ invalidateCredentials(scope) {
139
+ if (scope === 'all' || scope === 'tokens')
140
+ delete oauth.tokens;
141
+ if (scope === 'all' || scope === 'client')
142
+ delete oauth.clientInformation;
143
+ if (scope === 'all' || scope === 'discovery')
144
+ delete oauth.discovery;
145
+ if (scope === 'all' || scope === 'verifier')
146
+ verifier = undefined;
147
+ if (scope !== 'verifier')
148
+ touched();
149
+ }
150
+ };
151
+ }
152
+ /** What a probe needs for a URL: static headers, an OAuth provider, or nothing. */
153
+ export function credentialsFor(store, url, options = {}) {
154
+ const entry = entryFor(store, url);
155
+ if (!entry)
156
+ return undefined;
157
+ const out = {};
158
+ if (entry.headers && Object.keys(entry.headers).length)
159
+ out.headers = { ...entry.headers };
160
+ if (entry.oauth?.tokens || entry.oauth?.clientInformation)
161
+ out.authProvider = createProvider(store, url, options);
162
+ return out.headers || out.authProvider ? out : undefined;
163
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Exact token counts from the Gemini countTokens endpoint. The endpoint is free
3
+ * and calls no model. Same method as claude.ts: one request with a marker
4
+ * function plus the server's tools, one with the marker only, and the
5
+ * difference is what the tool definitions add for this model.
6
+ *
7
+ * Function declarations go in as `parametersJsonSchema`, so the server's JSON
8
+ * Schema is sent as it is, not converted to the OpenAPI subset.
9
+ *
10
+ * This file has no Node dependencies, so the Worker can import it too.
11
+ */
12
+ import type { ToolLike } from './payload.js';
13
+ import type { Limiter } from './claude.js';
14
+ export declare const GEMINI_MODEL = "gemini-3.8-flash";
15
+ export declare const GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/models";
16
+ export interface GeminiCountOptions {
17
+ apiKey: string;
18
+ /** Model id. Default gemini-3.8-flash. */
19
+ model?: string;
20
+ fetch?: typeof fetch;
21
+ /** Retries on 429 and 5xx. Default 5. */
22
+ retries?: number;
23
+ /** Timeout for one request. Default 30 s. */
24
+ timeoutMs?: number;
25
+ /** Shared rate limiter from createLimiter, for many servers in one process. */
26
+ limiter?: Limiter;
27
+ }
28
+ export interface GeminiCount {
29
+ model: string;
30
+ /** Tokens the tool definitions add to a request. */
31
+ tokens: number;
32
+ }
33
+ /** The answer when Gemini refuses to count, for example on a schema it does not accept. */
34
+ export declare class GeminiCountError extends Error {
35
+ readonly status: number;
36
+ constructor(message: string, status: number);
37
+ }
38
+ /** The tool list as a Gemini function declaration list. Names are made safe and unique. */
39
+ export declare function toGeminiDeclarations(tools: ToolLike[]): Array<{
40
+ name: string;
41
+ description: string;
42
+ parametersJsonSchema: unknown;
43
+ }>;
44
+ /** Exact tokens these tool definitions add to a request for this model. */
45
+ export declare function countGeminiTokens(tools: ToolLike[], options: GeminiCountOptions): Promise<GeminiCount>;
package/dist/gemini.js ADDED
@@ -0,0 +1,86 @@
1
+ export const GEMINI_MODEL = 'gemini-3.8-flash';
2
+ export const GEMINI_API_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
3
+ /** The answer when Gemini refuses to count, for example on a schema it does not accept. */
4
+ export class GeminiCountError extends Error {
5
+ status;
6
+ constructor(message, status) {
7
+ super(message);
8
+ this.status = status;
9
+ this.name = 'GeminiCountError';
10
+ }
11
+ }
12
+ const MARKER = { name: 'mcpcost_marker', description: 'marker', parametersJsonSchema: { type: 'object', properties: {} } };
13
+ const CONTENTS = [{ role: 'user', parts: [{ text: 'hi' }] }];
14
+ /** Gemini function names: letters, digits, underscores, colons, dots and dashes, at most 64 characters. */
15
+ const safeName = (name) => name.replace(/[^a-zA-Z0-9_:.-]/g, '_').slice(0, 64) || 'tool';
16
+ /** The tool list as a Gemini function declaration list. Names are made safe and unique. */
17
+ export function toGeminiDeclarations(tools) {
18
+ const seen = new Map();
19
+ return tools.map((t) => {
20
+ let name = safeName(t.name);
21
+ const n = seen.get(name) ?? 0;
22
+ seen.set(name, n + 1);
23
+ if (n)
24
+ name = `${name.slice(0, 60)}_${n + 1}`;
25
+ return {
26
+ name,
27
+ // The field is required; an empty description is sent as a space.
28
+ description: t.description || ' ',
29
+ parametersJsonSchema: t.inputSchema ?? { type: 'object', properties: {} }
30
+ };
31
+ });
32
+ }
33
+ async function countRequest(model, declarations, options) {
34
+ const doFetch = options.fetch ?? fetch;
35
+ const retries = options.retries ?? 5;
36
+ const body = { generateContentRequest: { model: `models/${model}`, contents: CONTENTS, tools: [{ functionDeclarations: declarations }] } };
37
+ for (let attempt = 0;; attempt++) {
38
+ if (options.limiter)
39
+ await options.limiter.wait();
40
+ let res;
41
+ try {
42
+ res = await doFetch(`${GEMINI_API_URL}/${model}:countTokens`, {
43
+ method: 'POST',
44
+ headers: { 'x-goog-api-key': options.apiKey, 'content-type': 'application/json' },
45
+ body: JSON.stringify(body),
46
+ signal: AbortSignal.timeout(options.timeoutMs ?? 30_000)
47
+ });
48
+ }
49
+ catch (e) {
50
+ if (attempt < retries) {
51
+ await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
52
+ continue;
53
+ }
54
+ throw new GeminiCountError(e instanceof Error ? e.message : String(e), 0);
55
+ }
56
+ if ((res.status === 429 || res.status >= 500) && attempt < retries) {
57
+ const wait = Number(res.headers.get('retry-after')) * 1000 || 1500 * 2 ** attempt;
58
+ await new Promise((r) => setTimeout(r, Math.min(wait, 30_000)));
59
+ continue;
60
+ }
61
+ const json = (await res.json().catch(() => ({})));
62
+ if (!res.ok)
63
+ throw new GeminiCountError((json.error?.message ?? `HTTP ${res.status}`).slice(0, 300), res.status);
64
+ return json.totalTokens ?? 0;
65
+ }
66
+ }
67
+ const baselines = new Map();
68
+ /** Tokens of the message plus the marker function, once per model and process. */
69
+ function baseline(model, options) {
70
+ let p = baselines.get(model);
71
+ if (!p) {
72
+ p = countRequest(model, [MARKER], options);
73
+ p.catch(() => baselines.delete(model));
74
+ baselines.set(model, p);
75
+ }
76
+ return p;
77
+ }
78
+ /** Exact tokens these tool definitions add to a request for this model. */
79
+ export async function countGeminiTokens(tools, options) {
80
+ const model = options.model ?? GEMINI_MODEL;
81
+ if (!tools.length)
82
+ return { model, tokens: 0 };
83
+ const base = await baseline(model, options);
84
+ const withTools = await countRequest(model, [MARKER, ...toGeminiDeclarations(tools)], options);
85
+ return { model, tokens: Math.max(0, withTools - base) };
86
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { fetchRegistry, primaryRemote, hostOf, REGISTRY_URL } from './registry.js';
2
2
  export { probeRemote, USER_AGENT, VERSION } from './probe.js';
3
- export type { ProbeOptions, ClaudeOptions } from './probe.js';
3
+ export type { ProbeOptions, ClaudeOptions, GeminiOptions } from './probe.js';
4
4
  export { probeStdio, probeStdioRaw } from './stdio.js';
5
5
  export { measure, emptyResult } from './core.js';
6
6
  export type { RawTool } from './core.js';
@@ -11,6 +11,10 @@ export { toolCost, toolsCost, toolPayload, countTokens, TOKENIZER } from './toke
11
11
  export type { ToolLike } from './tokens.js';
12
12
  export { countClaudeTokens, countForServer, claudeCodePrefix, toApiTools, createLimiter, ClaudeCountError, CLAUDE_MODEL } from './claude.js';
13
13
  export type { ClaudeCountOptions, ClaudeCount, Limiter } from './claude.js';
14
+ export { countGeminiTokens, toGeminiDeclarations, GeminiCountError, GEMINI_MODEL } from './gemini.js';
15
+ export type { GeminiCountOptions, GeminiCount } from './gemini.js';
14
16
  export type * from './types.js';
15
17
  export { GATED_STATUSES } from './types.js';
16
18
  export { buildServer } from './mcp.js';
19
+ export { loadCredentials, saveCredentials, defaultCredentialsPath, credentialKey, credentialsFor, createProvider, entryFor, describeEntry } from './credentials.js';
20
+ export type { CredentialStore, CredentialEntry, OAuthEntry, FileProvider, ProviderOptions } from './credentials.js';
package/dist/index.js CHANGED
@@ -5,5 +5,7 @@ export { measure, emptyResult } from './core.js';
5
5
  export { discoverConfigs, readConfigFile, normalizeServers, knownConfigPaths, expand } from './configs.js';
6
6
  export { toolCost, toolsCost, toolPayload, countTokens, TOKENIZER } from './tokens.js';
7
7
  export { countClaudeTokens, countForServer, claudeCodePrefix, toApiTools, createLimiter, ClaudeCountError, CLAUDE_MODEL } from './claude.js';
8
+ export { countGeminiTokens, toGeminiDeclarations, GeminiCountError, GEMINI_MODEL } from './gemini.js';
8
9
  export { GATED_STATUSES } from './types.js';
9
10
  export { buildServer } from './mcp.js';
11
+ export { loadCredentials, saveCredentials, defaultCredentialsPath, credentialKey, credentialsFor, createProvider, entryFor, describeEntry } from './credentials.js';
package/dist/probe.d.ts CHANGED
@@ -10,12 +10,22 @@ export interface ClaudeOptions {
10
10
  /** The server key in the client config, for the Claude Code mcp__<server>__ prefix. Default: the host or the command. */
11
11
  serverName?: string;
12
12
  }
13
+ export interface GeminiOptions {
14
+ /** Gemini API key. Only the free countTokens endpoint is called. */
15
+ apiKey: string;
16
+ /** Model id. Default gemini-3.8-flash. */
17
+ model?: string;
18
+ }
13
19
  export interface ProbeOptions extends CoreOptions {
14
20
  /** Keep the per-tool list in the result. Default true. */
15
21
  includeTools?: boolean;
16
22
  /** Also ask the Anthropic count_tokens endpoint for the exact count. */
17
23
  claude?: ClaudeOptions;
24
+ /** Also ask the Gemini countTokens endpoint for the exact count. */
25
+ gemini?: GeminiOptions;
18
26
  }
27
+ /** Adds the exact Gemini count to an alive result. A refused schema becomes `gemini.error`; a transport failure leaves `gemini` empty. */
28
+ export declare function attachGemini(result: ProbeResult, tools: RawTool[], gemini: GeminiOptions | undefined): Promise<ProbeResult>;
19
29
  /** Adds the exact Claude count to an alive result. A refused schema becomes `claude.error`; a transport failure leaves `claude` empty. */
20
30
  export declare function attachClaude(result: ProbeResult, tools: RawTool[], claude: ClaudeOptions | undefined): Promise<ProbeResult>;
21
31
  /** Adds the token cost of the listed tools to a probe result. */
package/dist/probe.js CHANGED
@@ -1,7 +1,24 @@
1
1
  import { probeRemoteRaw } from './core.js';
2
2
  import { toolsCost } from './tokens.js';
3
3
  import { countForServer, ClaudeCountError, CLAUDE_MODEL } from './claude.js';
4
+ import { countGeminiTokens, GeminiCountError, GEMINI_MODEL } from './gemini.js';
4
5
  export { VERSION, USER_AGENT, emptyResult, measure, classify } from './core.js';
6
+ /** Adds the exact Gemini count to an alive result. A refused schema becomes `gemini.error`; a transport failure leaves `gemini` empty. */
7
+ export async function attachGemini(result, tools, gemini) {
8
+ if (!gemini || result.status !== 'ok')
9
+ return result;
10
+ const measuredAt = new Date().toISOString();
11
+ try {
12
+ const c = await countGeminiTokens(tools, { apiKey: gemini.apiKey, model: gemini.model });
13
+ result.gemini = { model: c.model, tokens: c.tokens, measuredAt };
14
+ }
15
+ catch (e) {
16
+ const message = e instanceof Error ? e.message : String(e);
17
+ if (e instanceof GeminiCountError && e.status >= 400 && e.status < 500 && e.status !== 429)
18
+ result.gemini = { model: gemini.model ?? GEMINI_MODEL, measuredAt, error: message };
19
+ }
20
+ return result;
21
+ }
5
22
  /** Adds the exact Claude count to an alive result. A refused schema becomes `claude.error`; a transport failure leaves `claude` empty. */
6
23
  export async function attachClaude(result, tools, claude) {
7
24
  if (!claude || result.status !== 'ok')
@@ -35,5 +52,6 @@ export function priceTools(result, tools, includeTools = true) {
35
52
  export async function probeRemote(remote, options = {}) {
36
53
  const { result, tools } = await probeRemoteRaw(remote, options);
37
54
  priceTools(result, tools, options.includeTools ?? true);
38
- return attachClaude(result, tools, options.claude);
55
+ await attachClaude(result, tools, options.claude);
56
+ return attachGemini(result, tools, options.gemini);
39
57
  }
package/dist/stdio.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { StdioClientTransport, getDefaultEnvironment } from '@modelcontextprotocol/sdk/client/stdio.js';
2
- import { emptyResult, measure, priceTools, attachClaude } from './probe.js';
2
+ import { emptyResult, measure, priceTools, attachClaude, attachGemini } from './probe.js';
3
3
  /** Starts a local MCP server over stdio, lists its tools and measures what it costs. */
4
4
  export async function probeStdio(target, options = {}) {
5
5
  const { result, tools } = await probeStdioRaw(target, options);
6
6
  priceTools(result, tools, options.includeTools ?? true);
7
7
  await attachClaude(result, tools, options.claude);
8
+ await attachGemini(result, tools, options.gemini);
8
9
  return result;
9
10
  }
10
11
  /** The same, with the tool definitions as the server sent them, for callers that count tokens their own way. */
package/dist/types.d.ts CHANGED
@@ -102,6 +102,17 @@ export interface ProbeResult {
102
102
  tools?: ToolCost[];
103
103
  /** Exact count from the Anthropic count_tokens endpoint, when a key was available. */
104
104
  claude?: ClaudeTokens;
105
+ /** Exact count from the Gemini countTokens endpoint, when a key was available. */
106
+ gemini?: GeminiTokens;
107
+ error?: string;
108
+ }
109
+ export interface GeminiTokens {
110
+ /** Model id the count was made for, for example gemini-3.8-flash. */
111
+ model: string;
112
+ /** Tokens the tool definitions add, from the Gemini countTokens endpoint. */
113
+ tokens?: number;
114
+ measuredAt: string;
115
+ /** Why the count failed, for example a schema the API does not accept. */
105
116
  error?: string;
106
117
  }
107
118
  export interface ClaudeTokens {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcpcost",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "What do your MCP servers cost in context tokens? Measures every server in your config, exact counts for Claude, a budget for CI.",
5
5
  "keywords": [
6
6
  "mcp",