mcpcost 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +99 -2
- package/dist/auth-cli.d.ts +1 -0
- package/dist/auth-cli.js +194 -0
- package/dist/claude.d.ts +65 -0
- package/dist/claude.js +118 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +364 -0
- package/dist/configs.d.ts +41 -0
- package/dist/configs.js +127 -0
- package/dist/core.d.ts +37 -0
- package/dist/core.js +162 -0
- package/dist/credentials.d.ts +52 -0
- package/dist/credentials.js +163 -0
- package/dist/gemini.d.ts +45 -0
- package/dist/gemini.js +86 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +11 -0
- package/dist/mcp.d.ts +10 -0
- package/dist/mcp.js +173 -0
- package/dist/payload.d.ts +16 -0
- package/dist/payload.js +17 -0
- package/dist/probe.d.ts +37 -0
- package/dist/probe.js +57 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +54 -0
- package/dist/stdio.d.ts +16 -0
- package/dist/stdio.js +46 -0
- package/dist/tokens.d.ts +13 -0
- package/dist/tokens.js +31 -0
- package/dist/types.d.ts +128 -0
- package/dist/types.js +2 -0
- package/package.json +54 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ondřej Úlehla
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,100 @@
|
|
|
1
|
-
#
|
|
1
|
+
# mcpcost
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/mcpcost) [](https://github.com/ondraulehla/mcpcost/actions/workflows/ci.yml) [](LICENSE)
|
|
4
|
+
|
|
5
|
+
**`top` for your MCP servers: how many tokens do their tool definitions cost before the first prompt?**
|
|
6
|
+
|
|
7
|
+
mcpcost is the CLI of [mcpcost](https://github.com/ondraulehla/mcpcost), the board that probes every remote server in the official MCP registry. The board tells you about servers out there. mcpcost tells you about yours.
|
|
8
|
+
|
|
9
|
+
Every MCP server you configure hands your agent its tool definitions at the start of each session. Those definitions are tokens, and you pay for them in every turn. This command starts each configured server, lists its tools and tells you what they cost.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
$ npx mcpcost --config .mcp.json --tools 3
|
|
13
|
+
|
|
14
|
+
mcpcost 0.3.0 · tool definitions your agent loads before the first prompt
|
|
15
|
+
.mcp.json
|
|
16
|
+
|
|
17
|
+
server source result tools tokens of 200k
|
|
18
|
+
────────── ───────── ─────────────────── ───── ────── ───────
|
|
19
|
+
filesystem .mcp.json alive 14 1 650 0.8 %
|
|
20
|
+
everything .mcp.json alive 13 1 075 0.5 %
|
|
21
|
+
deepwiki .mcp.json alive 3 239 0.1 %
|
|
22
|
+
github .mcp.json needs auth (Bearer)
|
|
23
|
+
apify .mcp.json needs auth (Bearer)
|
|
24
|
+
────────── ───────── ─────────────────── ───── ────── ───────
|
|
25
|
+
total 3 of 5 measured 30 2 964 1.5 %
|
|
26
|
+
|
|
27
|
+
github: Streamable HTTP error: Error POSTing to endpoint: bad request: missing required Authorization header
|
|
28
|
+
apify: Streamable HTTP error: Error POSTing to endpoint: {"error":"invalid_token", ...
|
|
29
|
+
|
|
30
|
+
filesystem: most expensive tools
|
|
31
|
+
185 read_text_file
|
|
32
|
+
162 edit_file
|
|
33
|
+
148 search_files
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
That run measured this `.mcp.json`: the filesystem and everything reference servers over stdio, DeepWiki, the GitHub MCP and Apify over HTTP. Servers that need auth are reachable but not measured until you put the header in the config.
|
|
37
|
+
|
|
38
|
+
It reads the config files of Claude Code (`.mcp.json`, `~/.claude.json`), Claude Desktop, Cursor, VS Code, Windsurf and Gemini CLI. Remote servers are probed over Streamable HTTP or SSE with the headers from your config. Local servers are started over stdio with the command, args and env from your config, the same way your client starts them.
|
|
39
|
+
|
|
40
|
+
## Options
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
mcpcost [options] measure every server in the configs it can find
|
|
44
|
+
mcpcost --config <file> measure one config file (repeatable)
|
|
45
|
+
mcpcost --url <url> measure one remote server (repeatable)
|
|
46
|
+
mcpcost --stdio "<command args>" measure one local server (repeatable)
|
|
47
|
+
|
|
48
|
+
--window <tokens> context window for the percentages (default 200000)
|
|
49
|
+
--budget <tokens> exit with code 1 when the total is above this
|
|
50
|
+
--tools <n> list the n most expensive tools of each server
|
|
51
|
+
--claude exact counts from the Anthropic count_tokens endpoint (free, needs ANTHROPIC_API_KEY)
|
|
52
|
+
--model <id> model for --claude (default claude-opus-5-5)
|
|
53
|
+
--timeout <ms> per server (default 15000)
|
|
54
|
+
--no-stdio do not start local servers, only measure remote ones
|
|
55
|
+
--json machine-readable output
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`--budget` makes it a CI check: commit your `.mcp.json`, run `npx mcpcost --budget 30000` in the pipeline, and a pull request that adds a 40 000-token server fails.
|
|
59
|
+
|
|
60
|
+
## As an MCP server
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{ "mcpServers": { "mcpcost": { "command": "npx", "args": ["-y", "mcpcost", "mcp"] } } }
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`mcpcost mcp` serves the same measurements over stdio, so an agent can check a server before it is added to a config. Four tools: `measure_server` (a remote URL or a local command, probed now), `measure_config` (every server in a config file), `lookup_server` and `search_servers` (the public board, which probes the whole registry daily). With `ANTHROPIC_API_KEY` in the environment the counts are exact. The repository's `Dockerfile` builds the same server for containers.
|
|
67
|
+
|
|
68
|
+
## How tokens are counted
|
|
69
|
+
|
|
70
|
+
Two ways.
|
|
71
|
+
|
|
72
|
+
**Exact, with `--claude`.** mcpcost asks the Anthropic `count_tokens` endpoint. It sends one request with the server's tools and one request without them. The difference is what the tools cost, as the model sees them. The endpoint is free and calls no model. It needs `ANTHROPIC_API_KEY` in the environment. You get two numbers: the tools as published, and the tools as Claude Code names them (`mcp__<server>__<tool>`, where `<server>` is the key in your config). The default model is `claude-opus-5-5`. `--budget` then applies to the exact total.
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
ANTHROPIC_API_KEY=sk-ant-… npx mcpcost --claude
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Estimate, by default.** Each tool is serialised as compact JSON with `name`, `description` and `input_schema`, and counted with the `o200k_base` tokenizer. No key is needed. The exact Claude count is about 1.9 times this estimate at the median, because Claude renders schemas in its own format and tokenises prose in its own way. The estimate is the same for every server, so servers stay comparable.
|
|
79
|
+
|
|
80
|
+
The [public board](https://mcpcost.ulehla.dev) shows both numbers for every remote server in the official registry.
|
|
81
|
+
|
|
82
|
+
## Library
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { probeRemote, probeStdio, discoverConfigs } from 'mcpcost';
|
|
86
|
+
|
|
87
|
+
const r = await probeRemote(
|
|
88
|
+
{ url: 'https://mcp.deepwiki.com/mcp', type: 'streamable-http' },
|
|
89
|
+
{ claude: { apiKey: process.env.ANTHROPIC_API_KEY!, serverName: 'deepwiki' } } // optional
|
|
90
|
+
);
|
|
91
|
+
r.status; // 'ok' | 'auth' | 'payment' | 'not_found' | 'timeout' | ...
|
|
92
|
+
r.toolsTokens; // 239, the o200k_base estimate
|
|
93
|
+
r.claude?.tokens; // 438, exact for claude-opus-5-5
|
|
94
|
+
r.claude?.tokensClaudeCode; // 462, with the mcp__deepwiki__ prefix
|
|
95
|
+
r.tools; // [{ name, tokens, bytes, descriptionChars }, ...]
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Licence
|
|
99
|
+
|
|
100
|
+
MIT
|
|
@@ -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/claude.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact token counts from the Anthropic count_tokens endpoint. The endpoint is
|
|
3
|
+
* free and has its own rate limit. Nothing here calls a model.
|
|
4
|
+
*
|
|
5
|
+
* Method: count a request that holds one tiny marker tool plus the server's
|
|
6
|
+
* tools, and subtract the count of the same request with only the marker tool.
|
|
7
|
+
* Both requests carry the tool-use system prompt, so it cancels out. What is
|
|
8
|
+
* left is exactly what the server's tool definitions add, as this model
|
|
9
|
+
* renders and tokenizes them. No constants from the docs are needed.
|
|
10
|
+
*
|
|
11
|
+
* This file has no Node dependencies, so the Worker can import it too.
|
|
12
|
+
*/
|
|
13
|
+
import type { ToolLike } from './payload.js';
|
|
14
|
+
export declare const CLAUDE_MODEL = "claude-opus-5-5";
|
|
15
|
+
export declare const COUNT_TOKENS_URL = "https://api.anthropic.com/v1/messages/count_tokens";
|
|
16
|
+
export interface ClaudeCountOptions {
|
|
17
|
+
apiKey: string;
|
|
18
|
+
/** Model id. Default claude-opus-5-5. */
|
|
19
|
+
model?: string;
|
|
20
|
+
/** Prefix for every tool name, for example `mcp__github__` as Claude Code sends them. */
|
|
21
|
+
prefix?: string;
|
|
22
|
+
fetch?: typeof fetch;
|
|
23
|
+
/** Retries on 429 and 5xx. Default 5. */
|
|
24
|
+
retries?: number;
|
|
25
|
+
/** Timeout for one request. Default 30 s. */
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
/** Shared rate limiter from createLimiter, for many servers in one process. */
|
|
28
|
+
limiter?: Limiter;
|
|
29
|
+
}
|
|
30
|
+
export interface Limiter {
|
|
31
|
+
/** Resolves when the next request may start. */
|
|
32
|
+
wait(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Lets at most `rpm` requests start in any sliding minute. Organisations have a
|
|
36
|
+
* count_tokens limit of their own (100 a minute on a small one), and a 429
|
|
37
|
+
* costs more time than waiting for a slot.
|
|
38
|
+
*/
|
|
39
|
+
export declare function createLimiter(rpm: number): Limiter;
|
|
40
|
+
export interface ClaudeCount {
|
|
41
|
+
model: string;
|
|
42
|
+
/** Tokens the tool definitions add to a request. */
|
|
43
|
+
tokens: number;
|
|
44
|
+
}
|
|
45
|
+
/** The answer when Claude refuses to count, for example on a schema it does not accept. */
|
|
46
|
+
export declare class ClaudeCountError extends Error {
|
|
47
|
+
readonly status: number;
|
|
48
|
+
constructor(message: string, status: number);
|
|
49
|
+
}
|
|
50
|
+
/** The tool list as the Messages API takes it. Names are made safe and unique, because the API refuses duplicates. */
|
|
51
|
+
export declare function toApiTools(tools: ToolLike[], prefix?: string): Array<{
|
|
52
|
+
name: string;
|
|
53
|
+
description: string;
|
|
54
|
+
input_schema: unknown;
|
|
55
|
+
}>;
|
|
56
|
+
/** Exact tokens these tool definitions add to a request for this model. */
|
|
57
|
+
export declare function countClaudeTokens(tools: ToolLike[], options: ClaudeCountOptions): Promise<ClaudeCount>;
|
|
58
|
+
/** Claude Code names a tool mcp__<server>__<tool>. The server part is the key in the config. */
|
|
59
|
+
export declare function claudeCodePrefix(serverName: string): string;
|
|
60
|
+
/** Exact counts for a server: as published, and as Claude Code sends them. */
|
|
61
|
+
export declare function countForServer(tools: ToolLike[], serverName: string, options: ClaudeCountOptions): Promise<{
|
|
62
|
+
model: string;
|
|
63
|
+
tokens: number;
|
|
64
|
+
tokensClaudeCode: number;
|
|
65
|
+
}>;
|
package/dist/claude.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export const CLAUDE_MODEL = 'claude-opus-5-5';
|
|
2
|
+
export const COUNT_TOKENS_URL = 'https://api.anthropic.com/v1/messages/count_tokens';
|
|
3
|
+
/**
|
|
4
|
+
* Lets at most `rpm` requests start in any sliding minute. Organisations have a
|
|
5
|
+
* count_tokens limit of their own (100 a minute on a small one), and a 429
|
|
6
|
+
* costs more time than waiting for a slot.
|
|
7
|
+
*/
|
|
8
|
+
export function createLimiter(rpm) {
|
|
9
|
+
const starts = [];
|
|
10
|
+
let chain = Promise.resolve();
|
|
11
|
+
return {
|
|
12
|
+
wait() {
|
|
13
|
+
chain = chain.then(async () => {
|
|
14
|
+
for (;;) {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
while (starts.length && now - starts[0] >= 60_000)
|
|
17
|
+
starts.shift();
|
|
18
|
+
if (starts.length < rpm)
|
|
19
|
+
break;
|
|
20
|
+
await new Promise((r) => setTimeout(r, starts[0] + 60_000 - now + 20));
|
|
21
|
+
}
|
|
22
|
+
starts.push(Date.now());
|
|
23
|
+
});
|
|
24
|
+
return chain;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** The answer when Claude refuses to count, for example on a schema it does not accept. */
|
|
29
|
+
export class ClaudeCountError extends Error {
|
|
30
|
+
status;
|
|
31
|
+
constructor(message, status) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.status = status;
|
|
34
|
+
this.name = 'ClaudeCountError';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const MARKER = { name: 'mcp_pulse_marker', description: '', input_schema: { type: 'object', properties: {} } };
|
|
38
|
+
const MESSAGES = [{ role: 'user', content: 'hi' }];
|
|
39
|
+
const safeName = (name) => name.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 128) || 'tool';
|
|
40
|
+
/** The tool list as the Messages API takes it. Names are made safe and unique, because the API refuses duplicates. */
|
|
41
|
+
export function toApiTools(tools, prefix = '') {
|
|
42
|
+
const seen = new Map();
|
|
43
|
+
return tools.map((t) => {
|
|
44
|
+
let name = safeName(prefix + t.name);
|
|
45
|
+
const n = seen.get(name) ?? 0;
|
|
46
|
+
seen.set(name, n + 1);
|
|
47
|
+
if (n)
|
|
48
|
+
name = `${name.slice(0, 124)}_${n + 1}`;
|
|
49
|
+
return {
|
|
50
|
+
name,
|
|
51
|
+
description: t.description ?? '',
|
|
52
|
+
input_schema: t.inputSchema ?? { type: 'object', properties: {} }
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async function countRequest(body, options) {
|
|
57
|
+
const doFetch = options.fetch ?? fetch;
|
|
58
|
+
const retries = options.retries ?? 5;
|
|
59
|
+
for (let attempt = 0;; attempt++) {
|
|
60
|
+
if (options.limiter)
|
|
61
|
+
await options.limiter.wait();
|
|
62
|
+
let res;
|
|
63
|
+
try {
|
|
64
|
+
res = await doFetch(COUNT_TOKENS_URL, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'x-api-key': options.apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
|
|
67
|
+
body: JSON.stringify(body),
|
|
68
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000)
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
if (attempt < retries) {
|
|
73
|
+
await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
throw new ClaudeCountError(e instanceof Error ? e.message : String(e), 0);
|
|
77
|
+
}
|
|
78
|
+
if ((res.status === 429 || res.status >= 500) && attempt < retries) {
|
|
79
|
+
const wait = Number(res.headers.get('retry-after')) * 1000 || 1500 * 2 ** attempt;
|
|
80
|
+
await new Promise((r) => setTimeout(r, Math.min(wait, 30_000)));
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const json = (await res.json().catch(() => ({})));
|
|
84
|
+
if (!res.ok)
|
|
85
|
+
throw new ClaudeCountError((json.error?.message ?? `HTTP ${res.status}`).slice(0, 300), res.status);
|
|
86
|
+
return json.input_tokens ?? 0;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const baselines = new Map();
|
|
90
|
+
/** Tokens of the message plus the marker tool, once per model and process. */
|
|
91
|
+
function baseline(model, options) {
|
|
92
|
+
let p = baselines.get(model);
|
|
93
|
+
if (!p) {
|
|
94
|
+
p = countRequest({ model, messages: MESSAGES, tools: [MARKER] }, options);
|
|
95
|
+
p.catch(() => baselines.delete(model));
|
|
96
|
+
baselines.set(model, p);
|
|
97
|
+
}
|
|
98
|
+
return p;
|
|
99
|
+
}
|
|
100
|
+
/** Exact tokens these tool definitions add to a request for this model. */
|
|
101
|
+
export async function countClaudeTokens(tools, options) {
|
|
102
|
+
const model = options.model ?? CLAUDE_MODEL;
|
|
103
|
+
if (!tools.length)
|
|
104
|
+
return { model, tokens: 0 };
|
|
105
|
+
const base = await baseline(model, options);
|
|
106
|
+
const withTools = await countRequest({ model, messages: MESSAGES, tools: [MARKER, ...toApiTools(tools, options.prefix)] }, options);
|
|
107
|
+
return { model, tokens: Math.max(0, withTools - base) };
|
|
108
|
+
}
|
|
109
|
+
/** Claude Code names a tool mcp__<server>__<tool>. The server part is the key in the config. */
|
|
110
|
+
export function claudeCodePrefix(serverName) {
|
|
111
|
+
const slug = (serverName.split('/').pop() ?? serverName).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
112
|
+
return `mcp__${slug}__`;
|
|
113
|
+
}
|
|
114
|
+
/** Exact counts for a server: as published, and as Claude Code sends them. */
|
|
115
|
+
export async function countForServer(tools, serverName, options) {
|
|
116
|
+
const [plain, cc] = await Promise.all([countClaudeTokens(tools, options), countClaudeTokens(tools, { ...options, prefix: claudeCodePrefix(serverName) })]);
|
|
117
|
+
return { model: plain.model, tokens: plain.tokens, tokensClaudeCode: cc.tokens };
|
|
118
|
+
}
|
package/dist/cli.d.ts
ADDED