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 +1 -1
- package/dist/auth-cli.d.ts +1 -0
- package/dist/auth-cli.js +194 -0
- package/dist/cli.js +91 -19
- package/dist/core.d.ts +5 -2
- package/dist/core.js +3 -3
- 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 +5 -1
- package/dist/index.js +2 -0
- package/dist/probe.d.ts +10 -0
- package/dist/probe.js +19 -1
- package/dist/stdio.js +2 -1
- package/dist/types.d.ts +11 -0
- package/package.json +1 -1
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.
|
|
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>;
|
package/dist/auth-cli.js
ADDED
|
@@ -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
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
const
|
|
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 =
|
|
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:
|
|
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,
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
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.
|
|
265
|
-
lines.push(''
|
|
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.
|
|
4
|
-
export declare const USER_AGENT = "mcpcost/0.
|
|
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.
|
|
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
|
+
}
|
package/dist/gemini.d.ts
ADDED
|
@@ -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
|
-
|
|
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