mcpcost 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +99 -2
- package/dist/claude.d.ts +65 -0
- package/dist/claude.js +118 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +292 -0
- package/dist/configs.d.ts +41 -0
- package/dist/configs.js +127 -0
- package/dist/core.d.ts +34 -0
- package/dist/core.js +162 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +9 -0
- package/dist/mcp.d.ts +10 -0
- package/dist/mcp.js +173 -0
- package/dist/payload.d.ts +16 -0
- package/dist/payload.js +17 -0
- package/dist/probe.d.ts +27 -0
- package/dist/probe.js +39 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +54 -0
- package/dist/stdio.d.ts +16 -0
- package/dist/stdio.js +45 -0
- package/dist/tokens.d.ts +13 -0
- package/dist/tokens.js +31 -0
- package/dist/types.d.ts +117 -0
- package/dist/types.js +2 -0
- package/package.json +54 -4
package/dist/configs.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
/** Expands ${VAR} and ${VAR:-default} the way Claude Code does in .mcp.json. */
|
|
5
|
+
export function expand(value, env) {
|
|
6
|
+
return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (whole, name, fallback) => {
|
|
7
|
+
const v = env[name];
|
|
8
|
+
if (v !== undefined)
|
|
9
|
+
return v;
|
|
10
|
+
if (fallback !== undefined)
|
|
11
|
+
return fallback;
|
|
12
|
+
return whole;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
function expandRecord(record, env) {
|
|
16
|
+
if (!record)
|
|
17
|
+
return undefined;
|
|
18
|
+
const out = {};
|
|
19
|
+
for (const [k, v] of Object.entries(record))
|
|
20
|
+
if (typeof v === 'string')
|
|
21
|
+
out[k] = expand(v, env);
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
function remoteType(raw, url) {
|
|
25
|
+
const t = String(raw.type ?? raw.transport ?? '').toLowerCase();
|
|
26
|
+
if (t === 'sse')
|
|
27
|
+
return 'sse';
|
|
28
|
+
if (t === 'http' || t === 'streamable-http' || t === 'streamablehttp' || t === 'streamable_http')
|
|
29
|
+
return 'streamable-http';
|
|
30
|
+
return /\/sse\/?$/.test(url) ? 'sse' : 'streamable-http';
|
|
31
|
+
}
|
|
32
|
+
/** Normalises one `{ name: {...} }` map of servers. Accepts the shapes Claude, Cursor, Windsurf and VS Code write. */
|
|
33
|
+
export function normalizeServers(map, source, env = process.env) {
|
|
34
|
+
if (!map || typeof map !== 'object')
|
|
35
|
+
return [];
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const [name, value] of Object.entries(map)) {
|
|
38
|
+
if (!value || typeof value !== 'object')
|
|
39
|
+
continue;
|
|
40
|
+
const raw = value;
|
|
41
|
+
const disabled = raw.disabled === true || raw.enabled === false;
|
|
42
|
+
const url = typeof raw.url === 'string' ? expand(raw.url, env) : typeof raw.serverUrl === 'string' ? expand(raw.serverUrl, env) : undefined;
|
|
43
|
+
if (url && String(raw.type ?? '').toLowerCase() !== 'stdio') {
|
|
44
|
+
out.push({ name, source, kind: 'remote', url, type: remoteType(raw, url), headers: expandRecord(raw.headers, env), disabled });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (typeof raw.command === 'string') {
|
|
48
|
+
const args = Array.isArray(raw.args) ? raw.args.filter((a) => typeof a === 'string').map((a) => expand(a, env)) : [];
|
|
49
|
+
out.push({
|
|
50
|
+
name,
|
|
51
|
+
source,
|
|
52
|
+
kind: 'stdio',
|
|
53
|
+
command: expand(raw.command, env),
|
|
54
|
+
args,
|
|
55
|
+
env: expandRecord(raw.env, env),
|
|
56
|
+
cwd: typeof raw.cwd === 'string' ? expand(raw.cwd, env) : undefined,
|
|
57
|
+
disabled
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
async function readJson(file) {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Parses one config file of a known client. Returns null when the file is missing or not JSON. */
|
|
72
|
+
export async function readConfigFile(file, client, options = {}) {
|
|
73
|
+
const env = options.env ?? process.env;
|
|
74
|
+
const json = await readJson(file);
|
|
75
|
+
if (!json)
|
|
76
|
+
return null;
|
|
77
|
+
const servers = [];
|
|
78
|
+
if (client === 'Claude Code (user)') {
|
|
79
|
+
servers.push(...normalizeServers(json.mcpServers, `${file} (user)`, env));
|
|
80
|
+
const projects = (json.projects ?? {});
|
|
81
|
+
const cwd = options.cwd ?? process.cwd();
|
|
82
|
+
for (const [projectPath, project] of Object.entries(projects)) {
|
|
83
|
+
if (projectPath === cwd || cwd.startsWith(projectPath.replace(/\/?$/, '/'))) {
|
|
84
|
+
servers.push(...normalizeServers(project.mcpServers, `${file} (project ${projectPath})`, env));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else if (json.servers && typeof json.servers === 'object') {
|
|
89
|
+
servers.push(...normalizeServers(json.servers, file, env));
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
servers.push(...normalizeServers(json.mcpServers, file, env));
|
|
93
|
+
}
|
|
94
|
+
return { path: file, client, servers };
|
|
95
|
+
}
|
|
96
|
+
/** The places the common clients keep their MCP config. */
|
|
97
|
+
export function knownConfigPaths(options = {}) {
|
|
98
|
+
const cwd = options.cwd ?? process.cwd();
|
|
99
|
+
const home = options.home ?? homedir();
|
|
100
|
+
const platform = options.platform ?? process.platform;
|
|
101
|
+
const env = options.env ?? process.env;
|
|
102
|
+
const desktop = platform === 'darwin'
|
|
103
|
+
? path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
|
|
104
|
+
: platform === 'win32'
|
|
105
|
+
? path.join(env.APPDATA ?? path.join(home, 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json')
|
|
106
|
+
: path.join(home, '.config', 'Claude', 'claude_desktop_config.json');
|
|
107
|
+
return [
|
|
108
|
+
{ path: path.join(cwd, '.mcp.json'), client: 'Claude Code (project)' },
|
|
109
|
+
{ path: path.join(home, '.claude.json'), client: 'Claude Code (user)' },
|
|
110
|
+
{ path: desktop, client: 'Claude Desktop' },
|
|
111
|
+
{ path: path.join(cwd, '.cursor', 'mcp.json'), client: 'Cursor (project)' },
|
|
112
|
+
{ path: path.join(home, '.cursor', 'mcp.json'), client: 'Cursor (user)' },
|
|
113
|
+
{ path: path.join(cwd, '.vscode', 'mcp.json'), client: 'VS Code (workspace)' },
|
|
114
|
+
{ path: path.join(home, '.codeium', 'windsurf', 'mcp_config.json'), client: 'Windsurf' },
|
|
115
|
+
{ path: path.join(home, '.gemini', 'settings.json'), client: 'Gemini CLI' }
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
/** Reads every known config file that exists. */
|
|
119
|
+
export async function discoverConfigs(options = {}) {
|
|
120
|
+
const found = [];
|
|
121
|
+
for (const { path: file, client } of knownConfigPaths(options)) {
|
|
122
|
+
const cfg = await readConfigFile(file, client, options);
|
|
123
|
+
if (cfg)
|
|
124
|
+
found.push(cfg);
|
|
125
|
+
}
|
|
126
|
+
return found;
|
|
127
|
+
}
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
2
|
+
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)";
|
|
5
|
+
export interface RawTool {
|
|
6
|
+
name: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
inputSchema?: unknown;
|
|
9
|
+
}
|
|
10
|
+
export interface CoreOptions {
|
|
11
|
+
/** Timeout for the whole probe and for each request. Default 15 000 ms. */
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
/** Extra request headers, for example an Authorization header from your config. */
|
|
14
|
+
headers?: Record<string, string>;
|
|
15
|
+
fetch?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
export interface RawProbe {
|
|
18
|
+
result: ProbeResult;
|
|
19
|
+
tools: RawTool[];
|
|
20
|
+
}
|
|
21
|
+
interface Observed {
|
|
22
|
+
status: number;
|
|
23
|
+
headers: Headers;
|
|
24
|
+
url: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function emptyResult(url: string, transport: ProbeResult['transport']): ProbeResult;
|
|
27
|
+
/** Connects to a remote MCP server and lists its tools. No token counting. */
|
|
28
|
+
export declare function probeRemoteRaw(remote: {
|
|
29
|
+
url: string;
|
|
30
|
+
type: RemoteTransport;
|
|
31
|
+
}, options?: CoreOptions): Promise<RawProbe>;
|
|
32
|
+
export declare function measure(transport: Transport, result: ProbeResult, timeoutMs: number): Promise<RawTool[]>;
|
|
33
|
+
export declare function classify(message: string | undefined, first: Observed | undefined): ProbeStatus;
|
|
34
|
+
export {};
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transport-level probe without token counting. The board's Worker imports
|
|
3
|
+
* this file, so it must not pull in the tokenizer (its tables take CPU to load).
|
|
4
|
+
* probe.ts and stdio.ts add the token costs on top.
|
|
5
|
+
*/
|
|
6
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
7
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
8
|
+
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
9
|
+
export const VERSION = '0.2.0';
|
|
10
|
+
export const USER_AGENT = `mcpcost/${VERSION} (mcpcost; +https://github.com/ondraulehla/mcpcost)`;
|
|
11
|
+
export function emptyResult(url, transport) {
|
|
12
|
+
return { url, transport, probedAt: new Date().toISOString(), status: 'error', latencyMs: {} };
|
|
13
|
+
}
|
|
14
|
+
/** Connects to a remote MCP server and lists its tools. No token counting. */
|
|
15
|
+
export async function probeRemoteRaw(remote, options = {}) {
|
|
16
|
+
const timeoutMs = options.timeoutMs ?? 15_000;
|
|
17
|
+
const baseFetch = options.fetch ?? fetch;
|
|
18
|
+
const result = emptyResult(remote.url, remote.type);
|
|
19
|
+
let url;
|
|
20
|
+
try {
|
|
21
|
+
url = new URL(remote.url);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
result.error = 'invalid URL';
|
|
25
|
+
return { result, tools: [] };
|
|
26
|
+
}
|
|
27
|
+
let first;
|
|
28
|
+
const observingFetch = async (input, init) => {
|
|
29
|
+
// The probe needs no server-initiated messages. Answering 405 to the standalone
|
|
30
|
+
// GET makes the SDK skip that stream, which otherwise stalls tools/list on some
|
|
31
|
+
// servers (DeepWiki held it for 15 s and never answered the POST meanwhile).
|
|
32
|
+
if (remote.type !== 'sse' && (init?.method ?? 'GET') === 'GET') {
|
|
33
|
+
return new Response(null, { status: 405 });
|
|
34
|
+
}
|
|
35
|
+
const signal = init?.signal
|
|
36
|
+
? AbortSignal.any([init.signal, AbortSignal.timeout(timeoutMs)])
|
|
37
|
+
: AbortSignal.timeout(timeoutMs);
|
|
38
|
+
const headers = new Headers(init?.headers);
|
|
39
|
+
headers.set('user-agent', USER_AGENT);
|
|
40
|
+
const res = await baseFetch(input, { ...init, headers, signal });
|
|
41
|
+
first ??= { status: res.status, headers: res.headers, url: String(input instanceof Request ? input.url : input) };
|
|
42
|
+
return res;
|
|
43
|
+
};
|
|
44
|
+
// Follow redirects across origins too: a server that moved should be measured where it lives.
|
|
45
|
+
const requestInit = { headers: options.headers };
|
|
46
|
+
const transport = remote.type === 'sse'
|
|
47
|
+
? new SSEClientTransport(url, { fetch: observingFetch, requestInit, redirectPolicy: 'follow' })
|
|
48
|
+
: new StreamableHTTPClientTransport(url, { fetch: observingFetch, requestInit, redirectPolicy: 'follow' });
|
|
49
|
+
const tools = await measure(transport, result, timeoutMs);
|
|
50
|
+
if (result.status === 'ok') {
|
|
51
|
+
result.httpStatus = first?.status;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
result.status = classify(result.error, first);
|
|
55
|
+
if (first) {
|
|
56
|
+
result.httpStatus = first.status;
|
|
57
|
+
const www = first.headers.get('www-authenticate');
|
|
58
|
+
if (www) {
|
|
59
|
+
result.authScheme = www.split(/[\s,]/)[0];
|
|
60
|
+
const m = /resource_metadata="([^"]+)"/i.exec(www);
|
|
61
|
+
if (m)
|
|
62
|
+
result.authResourceMetadata = m[1];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { result, tools };
|
|
67
|
+
}
|
|
68
|
+
/** The part every transport shares: initialize, read the server info, list the tools. */
|
|
69
|
+
/**
|
|
70
|
+
* The probe never calls a tool, so it never validates a tool result. A no-op
|
|
71
|
+
* validator skips the SDK's default, which compiles schemas with new Function
|
|
72
|
+
* and is refused inside Cloudflare Workers.
|
|
73
|
+
*/
|
|
74
|
+
const noValidation = {
|
|
75
|
+
getValidator() {
|
|
76
|
+
return (input) => ({ valid: true, data: input, errorMessage: undefined });
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
export async function measure(transport, result, timeoutMs) {
|
|
80
|
+
const client = new Client({ name: 'mcpcost', version: VERSION }, { capabilities: {}, jsonSchemaValidator: noValidation });
|
|
81
|
+
let timer;
|
|
82
|
+
const deadline = new Promise((_, reject) => {
|
|
83
|
+
timer = setTimeout(() => reject(Object.assign(new Error(`probe timed out after ${timeoutMs} ms`), { name: 'TimeoutError' })), timeoutMs);
|
|
84
|
+
timer.unref?.();
|
|
85
|
+
});
|
|
86
|
+
const tools = [];
|
|
87
|
+
try {
|
|
88
|
+
const t0 = performance.now();
|
|
89
|
+
await Promise.race([client.connect(transport, { timeout: timeoutMs }), deadline]);
|
|
90
|
+
result.latencyMs.initialize = Math.round(performance.now() - t0);
|
|
91
|
+
const info = client.getServerVersion();
|
|
92
|
+
if (info)
|
|
93
|
+
result.serverInfo = { name: info.name, version: info.version, title: info.title };
|
|
94
|
+
const caps = client.getServerCapabilities() ?? {};
|
|
95
|
+
result.capabilities = Object.keys(caps).sort();
|
|
96
|
+
result.instructionsChars = client.getInstructions()?.length ?? 0;
|
|
97
|
+
result.protocolVersion = transport.protocolVersion;
|
|
98
|
+
if (caps.tools) {
|
|
99
|
+
let cursor;
|
|
100
|
+
const t1 = performance.now();
|
|
101
|
+
do {
|
|
102
|
+
const page = await Promise.race([client.listTools(cursor ? { cursor } : undefined, { timeout: timeoutMs }), deadline]);
|
|
103
|
+
tools.push(...page.tools);
|
|
104
|
+
cursor = page.nextCursor;
|
|
105
|
+
} while (cursor && tools.length < 2_000);
|
|
106
|
+
result.latencyMs.toolsList = Math.round(performance.now() - t1);
|
|
107
|
+
}
|
|
108
|
+
result.toolCount = tools.length;
|
|
109
|
+
result.status = 'ok';
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
const e = err;
|
|
113
|
+
result.error = [e?.message ?? String(err), e?.cause?.message, e?.cause?.code].filter(Boolean).join(' | ').slice(0, 400);
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
if (timer)
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
await client.close().catch(() => undefined);
|
|
119
|
+
}
|
|
120
|
+
return tools;
|
|
121
|
+
}
|
|
122
|
+
export function classify(message, first) {
|
|
123
|
+
const text = message ?? '';
|
|
124
|
+
// The server speaks a protocol version that this SDK does not support yet.
|
|
125
|
+
if (/protocol version is not supported/i.test(text))
|
|
126
|
+
return 'newer_protocol';
|
|
127
|
+
if (first) {
|
|
128
|
+
if (first.status === 401 || first.status === 403)
|
|
129
|
+
return 'auth';
|
|
130
|
+
if (first.status === 402)
|
|
131
|
+
return 'payment';
|
|
132
|
+
if (first.status === 404 || first.status === 405 || first.status === 410)
|
|
133
|
+
return 'not_found';
|
|
134
|
+
if (first.status === 429)
|
|
135
|
+
return 'rate_limited';
|
|
136
|
+
// Inside Cloudflare Workers a failed origin fetch comes back as a 52x/530 page.
|
|
137
|
+
if (first.status === 530 || /error code: 10(00|16)/.test(text))
|
|
138
|
+
return 'dns';
|
|
139
|
+
if (first.status === 522 || first.status === 524)
|
|
140
|
+
return 'timeout';
|
|
141
|
+
if (first.status === 521 || first.status === 523)
|
|
142
|
+
return 'refused';
|
|
143
|
+
if (first.status === 525 || first.status === 526)
|
|
144
|
+
return 'tls';
|
|
145
|
+
if (first.status >= 500)
|
|
146
|
+
return 'server_error';
|
|
147
|
+
if (first.status >= 200 && first.status < 300) {
|
|
148
|
+
if (/timed? ?out|TimeoutError/i.test(text))
|
|
149
|
+
return 'timeout';
|
|
150
|
+
return 'protocol_error';
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (/timed? ?out|TimeoutError|UND_ERR_CONNECT_TIMEOUT|UND_ERR_HEADERS_TIMEOUT/i.test(text))
|
|
154
|
+
return 'timeout';
|
|
155
|
+
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(text))
|
|
156
|
+
return 'dns';
|
|
157
|
+
if (/CERT_|certificate|TLS|SSL|ERR_TLS/i.test(text))
|
|
158
|
+
return 'tls';
|
|
159
|
+
if (/ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ENETUNREACH|UND_ERR_SOCKET/i.test(text))
|
|
160
|
+
return 'refused';
|
|
161
|
+
return 'error';
|
|
162
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { fetchRegistry, primaryRemote, hostOf, REGISTRY_URL } from './registry.js';
|
|
2
|
+
export { probeRemote, USER_AGENT, VERSION } from './probe.js';
|
|
3
|
+
export type { ProbeOptions, ClaudeOptions } from './probe.js';
|
|
4
|
+
export { probeStdio, probeStdioRaw } from './stdio.js';
|
|
5
|
+
export { measure, emptyResult } from './core.js';
|
|
6
|
+
export type { RawTool } from './core.js';
|
|
7
|
+
export type { StdioTarget } from './stdio.js';
|
|
8
|
+
export { discoverConfigs, readConfigFile, normalizeServers, knownConfigPaths, expand } from './configs.js';
|
|
9
|
+
export type { ConfiguredServer, ConfigFile, DiscoverOptions } from './configs.js';
|
|
10
|
+
export { toolCost, toolsCost, toolPayload, countTokens, TOKENIZER } from './tokens.js';
|
|
11
|
+
export type { ToolLike } from './tokens.js';
|
|
12
|
+
export { countClaudeTokens, countForServer, claudeCodePrefix, toApiTools, createLimiter, ClaudeCountError, CLAUDE_MODEL } from './claude.js';
|
|
13
|
+
export type { ClaudeCountOptions, ClaudeCount, Limiter } from './claude.js';
|
|
14
|
+
export type * from './types.js';
|
|
15
|
+
export { GATED_STATUSES } from './types.js';
|
|
16
|
+
export { buildServer } from './mcp.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { fetchRegistry, primaryRemote, hostOf, REGISTRY_URL } from './registry.js';
|
|
2
|
+
export { probeRemote, USER_AGENT, VERSION } from './probe.js';
|
|
3
|
+
export { probeStdio, probeStdioRaw } from './stdio.js';
|
|
4
|
+
export { measure, emptyResult } from './core.js';
|
|
5
|
+
export { discoverConfigs, readConfigFile, normalizeServers, knownConfigPaths, expand } from './configs.js';
|
|
6
|
+
export { toolCost, toolsCost, toolPayload, countTokens, TOKENIZER } from './tokens.js';
|
|
7
|
+
export { countClaudeTokens, countForServer, claudeCodePrefix, toApiTools, createLimiter, ClaudeCountError, CLAUDE_MODEL } from './claude.js';
|
|
8
|
+
export { GATED_STATUSES } from './types.js';
|
|
9
|
+
export { buildServer } from './mcp.js';
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcpcost as an MCP server over stdio: the same measurements as the CLI, for
|
|
3
|
+
* an agent that wants to know what a server costs before it is added to a
|
|
4
|
+
* config. Two tools work offline (they probe the server themselves); two ask
|
|
5
|
+
* the public board for registry data.
|
|
6
|
+
*/
|
|
7
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
8
|
+
export declare function buildServer(): Server;
|
|
9
|
+
/** Serves the tools over stdio until the client closes the pipe. */
|
|
10
|
+
export declare function serveStdio(): Promise<void>;
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcpcost as an MCP server over stdio: the same measurements as the CLI, for
|
|
3
|
+
* an agent that wants to know what a server costs before it is added to a
|
|
4
|
+
* config. Two tools work offline (they probe the server themselves); two ask
|
|
5
|
+
* the public board for registry data.
|
|
6
|
+
*/
|
|
7
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
8
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
9
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
10
|
+
import { VERSION, probeRemote } from './probe.js';
|
|
11
|
+
import { probeStdio } from './stdio.js';
|
|
12
|
+
import { readConfigFile } from './configs.js';
|
|
13
|
+
const BOARD = 'https://mcpcost.ulehla.dev';
|
|
14
|
+
function compact(r, window) {
|
|
15
|
+
return {
|
|
16
|
+
status: r.status,
|
|
17
|
+
error: r.error,
|
|
18
|
+
protocolVersion: r.protocolVersion,
|
|
19
|
+
serverInfo: r.serverInfo,
|
|
20
|
+
toolCount: r.toolCount,
|
|
21
|
+
toolsTokensEstimate: r.toolsTokens,
|
|
22
|
+
claudeTokens: r.claude?.tokens,
|
|
23
|
+
claudeTokensClaudeCode: r.claude?.tokensClaudeCode,
|
|
24
|
+
claudeError: r.claude?.error,
|
|
25
|
+
shareOfWindow: r.toolsTokens != null ? Number((((r.claude?.tokens ?? r.toolsTokens) / window) * 100).toFixed(2)) : undefined,
|
|
26
|
+
latencyMs: r.latencyMs,
|
|
27
|
+
topTools: r.tools ? [...r.tools].sort((a, b) => b.tokens - a.tokens).slice(0, 10).map((t) => ({ name: t.name, tokens: t.tokens })) : undefined
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const claudeOptions = (serverName) => (process.env.ANTHROPIC_API_KEY ? { apiKey: process.env.ANTHROPIC_API_KEY, serverName } : undefined);
|
|
31
|
+
const text = (value) => ({ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }], structuredContent: value });
|
|
32
|
+
const TOOLS = [
|
|
33
|
+
{
|
|
34
|
+
name: 'measure_server',
|
|
35
|
+
title: 'Measure one server',
|
|
36
|
+
description: 'Starts or connects to one MCP server, lists its tools and reports the result, the protocol version and the token cost of its tool definitions. Give either a remote URL or a local command.',
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
url: { type: 'string', description: 'Remote endpoint, https://…/mcp or an SSE URL' },
|
|
41
|
+
command: { type: 'string', description: 'Local server command with its arguments, for example "npx -y @modelcontextprotocol/server-filesystem /tmp"' },
|
|
42
|
+
window: { type: 'integer', minimum: 1, description: 'Context window for the share, default 200000' }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'measure_config',
|
|
48
|
+
title: 'Measure a config file',
|
|
49
|
+
description: 'Measures every server in an MCP config file (.mcp.json, Claude Desktop, Cursor, VS Code or Windsurf format): starts each one the way the client would and reports tokens per server and in total.',
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
path: { type: 'string', description: 'Path to the config file' },
|
|
54
|
+
window: { type: 'integer', minimum: 1 },
|
|
55
|
+
noStdio: { type: 'boolean', description: 'Measure remote servers only, do not start local ones' }
|
|
56
|
+
},
|
|
57
|
+
required: ['path']
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'lookup_server',
|
|
62
|
+
title: 'Look up a registry server on the board',
|
|
63
|
+
description: 'Latest daily probe of a server in the official MCP registry: status, exact token cost in Claude, tool count, latency, history. Accepts the registry name (io.github.owner/name) or the server URL.',
|
|
64
|
+
inputSchema: { type: 'object', properties: { nameOrUrl: { type: 'string' } }, required: ['nameOrUrl'] }
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'search_servers',
|
|
68
|
+
title: 'Search the board',
|
|
69
|
+
description: 'Finds registry servers by words in their name, title, description or host, sorted by exact Claude token cost by default. Twenty per page.',
|
|
70
|
+
inputSchema: {
|
|
71
|
+
type: 'object',
|
|
72
|
+
properties: {
|
|
73
|
+
query: { type: 'string' },
|
|
74
|
+
status: { type: 'string', enum: ['ok', 'auth', 'payment', 'newer_protocol', 'down'] },
|
|
75
|
+
sort: { type: 'string', enum: ['claude', 'claude-asc', 'tokens', 'tools', 'latency', 'name', 'updated'] },
|
|
76
|
+
page: { type: 'integer', minimum: 1, maximum: 20 }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
];
|
|
81
|
+
const str = (a, k) => (typeof a[k] === 'string' ? a[k] : undefined);
|
|
82
|
+
const num = (a, k) => (typeof a[k] === 'number' ? a[k] : undefined);
|
|
83
|
+
const fail = (message) => ({ content: [{ type: 'text', text: message }], isError: true });
|
|
84
|
+
async function call(name, a) {
|
|
85
|
+
const w = num(a, 'window') ?? 200_000;
|
|
86
|
+
switch (name) {
|
|
87
|
+
case 'measure_server': {
|
|
88
|
+
const url = str(a, 'url');
|
|
89
|
+
const command = str(a, 'command');
|
|
90
|
+
if (url) {
|
|
91
|
+
const r = await probeRemote({ url, type: /\/sse\/?$/.test(url) ? 'sse' : 'streamable-http' }, { timeoutMs: 15_000, claude: claudeOptions(new URL(url).hostname) });
|
|
92
|
+
return text({ target: url, ...compact(r, w) });
|
|
93
|
+
}
|
|
94
|
+
if (command) {
|
|
95
|
+
const [cmd, ...args] = command.trim().split(/\s+/);
|
|
96
|
+
const r = await probeStdio({ command: cmd, args }, { timeoutMs: 20_000, claude: claudeOptions(cmd) });
|
|
97
|
+
return text({ target: command, ...compact(r, w) });
|
|
98
|
+
}
|
|
99
|
+
return fail('Give url or command.');
|
|
100
|
+
}
|
|
101
|
+
case 'measure_config': {
|
|
102
|
+
const path = str(a, 'path');
|
|
103
|
+
if (!path)
|
|
104
|
+
return fail('Give path.');
|
|
105
|
+
const cfg = await readConfigFile(path, 'config');
|
|
106
|
+
if (!cfg)
|
|
107
|
+
return fail(`Cannot read ${path}`);
|
|
108
|
+
const noStdio = a.noStdio === true;
|
|
109
|
+
const servers = await Promise.all(cfg.servers.map(async (s) => {
|
|
110
|
+
if (s.disabled)
|
|
111
|
+
return { name: s.name, skipped: 'disabled in config' };
|
|
112
|
+
if (s.kind === 'stdio' && noStdio)
|
|
113
|
+
return { name: s.name, skipped: 'local server, noStdio' };
|
|
114
|
+
const r = s.kind === 'remote'
|
|
115
|
+
? await probeRemote({ url: s.url, type: s.type ?? 'streamable-http' }, { timeoutMs: 15_000, headers: s.headers, claude: claudeOptions(s.name) })
|
|
116
|
+
: await probeStdio({ command: s.command, args: s.args, env: s.env, cwd: s.cwd }, { timeoutMs: 20_000, claude: claudeOptions(s.name) });
|
|
117
|
+
return { name: s.name, kind: s.kind, ...compact(r, w) };
|
|
118
|
+
}));
|
|
119
|
+
const measured = servers.filter((s) => 'status' in s && s.status === 'ok');
|
|
120
|
+
const total = measured.reduce((t, s) => t + (s.claudeTokens ?? s.toolsTokensEstimate ?? 0), 0);
|
|
121
|
+
return text({ path, total: { servers: measured.length, tokens: total, shareOfWindow: Number(((total / w) * 100).toFixed(2)) }, servers });
|
|
122
|
+
}
|
|
123
|
+
case 'lookup_server': {
|
|
124
|
+
const nameOrUrl = str(a, 'nameOrUrl');
|
|
125
|
+
if (!nameOrUrl)
|
|
126
|
+
return fail('Give nameOrUrl.');
|
|
127
|
+
const url = /^https?:\/\//.test(nameOrUrl) ? `${BOARD}/api/check.json?url=${encodeURIComponent(nameOrUrl)}` : `${BOARD}/api/s/${nameOrUrl}.json`;
|
|
128
|
+
const res = await fetch(url, { headers: { accept: 'application/json', 'user-agent': `mcpcost/${VERSION}` } });
|
|
129
|
+
if (!res.ok)
|
|
130
|
+
return fail(`The board has no server ${nameOrUrl} (HTTP ${res.status}). Use measure_server to probe it now.`);
|
|
131
|
+
return text(await res.json());
|
|
132
|
+
}
|
|
133
|
+
case 'search_servers': {
|
|
134
|
+
const u = new URL(`${BOARD}/api/servers.json`);
|
|
135
|
+
const query = str(a, 'query');
|
|
136
|
+
if (query)
|
|
137
|
+
u.searchParams.set('q', query);
|
|
138
|
+
const status = str(a, 'status');
|
|
139
|
+
if (status)
|
|
140
|
+
u.searchParams.set('status', status);
|
|
141
|
+
u.searchParams.set('sort', str(a, 'sort') ?? 'claude');
|
|
142
|
+
u.searchParams.set('page', String(num(a, 'page') ?? 1));
|
|
143
|
+
u.searchParams.set('per_page', '20');
|
|
144
|
+
const res = await fetch(u, { headers: { accept: 'application/json', 'user-agent': `mcpcost/${VERSION}` } });
|
|
145
|
+
if (!res.ok)
|
|
146
|
+
return fail(`The board did not answer (HTTP ${res.status}).`);
|
|
147
|
+
return text(await res.json());
|
|
148
|
+
}
|
|
149
|
+
default:
|
|
150
|
+
return fail(`Unknown tool ${name}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export function buildServer() {
|
|
154
|
+
const server = new Server({ name: 'mcpcost', version: VERSION }, {
|
|
155
|
+
capabilities: { tools: {} },
|
|
156
|
+
instructions: 'Measures what MCP servers cost in context tokens. measure_server probes one server (remote URL or local command) now. measure_config measures every server in a config file. lookup_server and search_servers read the public board at mcpcost.ulehla.dev, which probes the whole registry daily. Token numbers are o200k_base estimates; with ANTHROPIC_API_KEY set, claudeTokens is the exact count from the free count_tokens endpoint.'
|
|
157
|
+
});
|
|
158
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS.map((t) => ({ ...t })) }));
|
|
159
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
160
|
+
try {
|
|
161
|
+
return await call(req.params.name, (req.params.arguments ?? {}));
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
return fail(e instanceof Error ? e.message : String(e));
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
return server;
|
|
168
|
+
}
|
|
169
|
+
/** Serves the tools over stdio until the client closes the pipe. */
|
|
170
|
+
export async function serveStdio() {
|
|
171
|
+
const server = buildServer();
|
|
172
|
+
await server.connect(new StdioServerTransport());
|
|
173
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** The shape a tool takes when a client hands it to a model. No tokenizer here, so the Worker can import it. */
|
|
2
|
+
export interface ToolLike {
|
|
3
|
+
name: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
inputSchema?: unknown;
|
|
6
|
+
outputSchema?: unknown;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Serialises a tool the way a client sends it to a model: name, description and
|
|
10
|
+
* input schema, as compact JSON. Different clients wrap this differently, so the
|
|
11
|
+
* count is an estimate, and it is the same estimate for every server.
|
|
12
|
+
*/
|
|
13
|
+
export declare function toolPayload(tool: ToolLike): string;
|
|
14
|
+
export declare function payloadBytes(tool: ToolLike): number;
|
|
15
|
+
/** Median bytes per token over 12 968 measured servers (p10 4.03, p90 4.62). For estimates without the tokenizer. */
|
|
16
|
+
export declare const BYTES_PER_TOKEN = 4.42;
|
package/dist/payload.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialises a tool the way a client sends it to a model: name, description and
|
|
3
|
+
* input schema, as compact JSON. Different clients wrap this differently, so the
|
|
4
|
+
* count is an estimate, and it is the same estimate for every server.
|
|
5
|
+
*/
|
|
6
|
+
export function toolPayload(tool) {
|
|
7
|
+
return JSON.stringify({
|
|
8
|
+
name: tool.name,
|
|
9
|
+
description: tool.description ?? '',
|
|
10
|
+
input_schema: tool.inputSchema ?? { type: 'object' }
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
export function payloadBytes(tool) {
|
|
14
|
+
return new TextEncoder().encode(toolPayload(tool)).length;
|
|
15
|
+
}
|
|
16
|
+
/** Median bytes per token over 12 968 measured servers (p10 4.03, p90 4.62). For estimates without the tokenizer. */
|
|
17
|
+
export const BYTES_PER_TOKEN = 4.42;
|
package/dist/probe.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type CoreOptions, type RawTool } from './core.js';
|
|
2
|
+
import type { ProbeResult, RemoteTransport } from './types.js';
|
|
3
|
+
export { VERSION, USER_AGENT, emptyResult, measure, classify } from './core.js';
|
|
4
|
+
export type { RawTool, RawProbe } from './core.js';
|
|
5
|
+
export interface ClaudeOptions {
|
|
6
|
+
/** Anthropic API key. Only the free count_tokens endpoint is called. */
|
|
7
|
+
apiKey: string;
|
|
8
|
+
/** Model id. Default claude-opus-5-5. */
|
|
9
|
+
model?: string;
|
|
10
|
+
/** The server key in the client config, for the Claude Code mcp__<server>__ prefix. Default: the host or the command. */
|
|
11
|
+
serverName?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ProbeOptions extends CoreOptions {
|
|
14
|
+
/** Keep the per-tool list in the result. Default true. */
|
|
15
|
+
includeTools?: boolean;
|
|
16
|
+
/** Also ask the Anthropic count_tokens endpoint for the exact count. */
|
|
17
|
+
claude?: ClaudeOptions;
|
|
18
|
+
}
|
|
19
|
+
/** Adds the exact Claude count to an alive result. A refused schema becomes `claude.error`; a transport failure leaves `claude` empty. */
|
|
20
|
+
export declare function attachClaude(result: ProbeResult, tools: RawTool[], claude: ClaudeOptions | undefined): Promise<ProbeResult>;
|
|
21
|
+
/** Adds the token cost of the listed tools to a probe result. */
|
|
22
|
+
export declare function priceTools(result: ProbeResult, tools: RawTool[], includeTools?: boolean): ProbeResult;
|
|
23
|
+
/** Connects to a remote MCP server, lists its tools and measures what it costs. */
|
|
24
|
+
export declare function probeRemote(remote: {
|
|
25
|
+
url: string;
|
|
26
|
+
type: RemoteTransport;
|
|
27
|
+
}, options?: ProbeOptions): Promise<ProbeResult>;
|
package/dist/probe.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { probeRemoteRaw } from './core.js';
|
|
2
|
+
import { toolsCost } from './tokens.js';
|
|
3
|
+
import { countForServer, ClaudeCountError, CLAUDE_MODEL } from './claude.js';
|
|
4
|
+
export { VERSION, USER_AGENT, emptyResult, measure, classify } from './core.js';
|
|
5
|
+
/** Adds the exact Claude count to an alive result. A refused schema becomes `claude.error`; a transport failure leaves `claude` empty. */
|
|
6
|
+
export async function attachClaude(result, tools, claude) {
|
|
7
|
+
if (!claude || result.status !== 'ok')
|
|
8
|
+
return result;
|
|
9
|
+
const measuredAt = new Date().toISOString();
|
|
10
|
+
const serverName = claude.serverName ?? result.url.replace(/^stdio:/, '').replace(/^https?:\/\//, '').split(/[/\s]/)[0];
|
|
11
|
+
try {
|
|
12
|
+
const c = await countForServer(tools, serverName, { apiKey: claude.apiKey, model: claude.model });
|
|
13
|
+
result.claude = { model: c.model, tokens: c.tokens, tokensClaudeCode: c.tokensClaudeCode, measuredAt };
|
|
14
|
+
}
|
|
15
|
+
catch (e) {
|
|
16
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
17
|
+
if (e instanceof ClaudeCountError && e.status >= 400 && e.status < 500 && e.status !== 429)
|
|
18
|
+
result.claude = { model: claude.model ?? CLAUDE_MODEL, measuredAt, error: message };
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
/** Adds the token cost of the listed tools to a probe result. */
|
|
23
|
+
export function priceTools(result, tools, includeTools = true) {
|
|
24
|
+
if (result.status !== 'ok')
|
|
25
|
+
return result;
|
|
26
|
+
const cost = toolsCost(tools);
|
|
27
|
+
result.toolCount = tools.length;
|
|
28
|
+
result.toolsTokens = cost.tokens;
|
|
29
|
+
result.toolsBytes = cost.bytes;
|
|
30
|
+
if (includeTools)
|
|
31
|
+
result.tools = cost.tools;
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
/** Connects to a remote MCP server, lists its tools and measures what it costs. */
|
|
35
|
+
export async function probeRemote(remote, options = {}) {
|
|
36
|
+
const { result, tools } = await probeRemoteRaw(remote, options);
|
|
37
|
+
priceTools(result, tools, options.includeTools ?? true);
|
|
38
|
+
return attachClaude(result, tools, options.claude);
|
|
39
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { RegistryEntry, RegistryRemote } from './types.js';
|
|
2
|
+
export declare const REGISTRY_URL = "https://registry.modelcontextprotocol.io/v0/servers";
|
|
3
|
+
export interface FetchRegistryOptions {
|
|
4
|
+
/** Only the latest version of each server. Default true. */
|
|
5
|
+
latest?: boolean;
|
|
6
|
+
/** Page size, max 100. */
|
|
7
|
+
limit?: number;
|
|
8
|
+
/** Stop after this many entries (for tests and samples). */
|
|
9
|
+
max?: number;
|
|
10
|
+
fetch?: typeof fetch;
|
|
11
|
+
onPage?: (loaded: number) => void;
|
|
12
|
+
}
|
|
13
|
+
/** Downloads the registry listing page by page. */
|
|
14
|
+
export declare function fetchRegistry(options?: FetchRegistryOptions): Promise<RegistryEntry[]>;
|
|
15
|
+
/** Picks the remote endpoint to probe: streamable-http first, then sse. */
|
|
16
|
+
export declare function primaryRemote(entry: RegistryEntry): RegistryRemote | undefined;
|
|
17
|
+
export declare function hostOf(url: string): string;
|