context-xray 0.1.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.
@@ -0,0 +1,40 @@
1
+ export interface JsonRpcRequest {
2
+ jsonrpc: '2.0';
3
+ id: number | string;
4
+ method: string;
5
+ params?: unknown;
6
+ }
7
+ export interface JsonRpcNotification {
8
+ jsonrpc: '2.0';
9
+ method: string;
10
+ params?: unknown;
11
+ }
12
+ export interface JsonRpcError {
13
+ code: number;
14
+ message: string;
15
+ data?: unknown;
16
+ }
17
+ export interface JsonRpcResponse {
18
+ jsonrpc?: string;
19
+ id?: number | string | null;
20
+ result?: unknown;
21
+ error?: JsonRpcError;
22
+ }
23
+ /** Error codes the spec pins down. Servers get these wrong constantly. */
24
+ export declare const RPC: {
25
+ readonly PARSE_ERROR: -32700;
26
+ readonly INVALID_REQUEST: -32600;
27
+ readonly METHOD_NOT_FOUND: -32601;
28
+ readonly INVALID_PARAMS: -32602;
29
+ readonly INTERNAL_ERROR: -32603;
30
+ };
31
+ export declare class TimeoutError extends Error {
32
+ method: string;
33
+ ms: number;
34
+ constructor(method: string, ms: number);
35
+ }
36
+ export declare class TransportClosedError extends Error {
37
+ code?: number | null | undefined;
38
+ stderr?: string | undefined;
39
+ constructor(message: string, code?: number | null | undefined, stderr?: string | undefined);
40
+ }
@@ -0,0 +1,28 @@
1
+ /** Error codes the spec pins down. Servers get these wrong constantly. */
2
+ export const RPC = {
3
+ PARSE_ERROR: -32700,
4
+ INVALID_REQUEST: -32600,
5
+ METHOD_NOT_FOUND: -32601,
6
+ INVALID_PARAMS: -32602,
7
+ INTERNAL_ERROR: -32603,
8
+ };
9
+ export class TimeoutError extends Error {
10
+ method;
11
+ ms;
12
+ constructor(method, ms) {
13
+ super(`No response to \`${method}\` within ${ms}ms`);
14
+ this.method = method;
15
+ this.ms = ms;
16
+ this.name = 'TimeoutError';
17
+ }
18
+ }
19
+ export class TransportClosedError extends Error {
20
+ code;
21
+ stderr;
22
+ constructor(message, code, stderr) {
23
+ super(message);
24
+ this.code = code;
25
+ this.stderr = stderr;
26
+ this.name = 'TransportClosedError';
27
+ }
28
+ }
@@ -0,0 +1,55 @@
1
+ import type { Transport } from './transport.js';
2
+ import { type JsonRpcResponse } from './jsonrpc.js';
3
+ export interface StdioOptions {
4
+ command: string;
5
+ args: string[];
6
+ env?: Record<string, string>;
7
+ cwd?: string;
8
+ }
9
+ /**
10
+ * Newline-delimited JSON-RPC over a child process's stdio.
11
+ *
12
+ * Deliberately hand-rolled rather than built on the official SDK: the SDK
13
+ * discards anything it cannot parse, and the unparseable bytes are exactly
14
+ * what we are here to find. A single stray `console.log` in a server puts a
15
+ * non-JSON line on stdout, which corrupts the stream for every client that
16
+ * connects to it -- and the server author never sees an error.
17
+ */
18
+ export declare class StdioTransport implements Transport {
19
+ private opts;
20
+ readonly kind = "stdio";
21
+ readonly target: string;
22
+ private child;
23
+ private buffer;
24
+ private nextId;
25
+ private pending;
26
+ private exited;
27
+ /** stdout lines that were not valid JSON. Almost always a logging bug. */
28
+ readonly stdoutNoise: string[];
29
+ readonly stderr: string[];
30
+ /** Pipe faults (EPIPE and friends) seen after the child went away. */
31
+ readonly pipeErrors: string[];
32
+ /** Notifications the server pushed at us, kept for later assertions. */
33
+ readonly serverNotifications: JsonRpcResponse[];
34
+ constructor(opts: StdioOptions);
35
+ start(): Promise<void>;
36
+ private onStdout;
37
+ private consumeLine;
38
+ request(method: string, params?: unknown, timeoutMs?: number): Promise<JsonRpcResponse>;
39
+ /** Send a request with a caller-chosen id/shape, for malformed-input probes. */
40
+ requestRaw(payload: Record<string, unknown>, id: number | string, method: string, timeoutMs?: number): Promise<JsonRpcResponse>;
41
+ private send;
42
+ notify(method: string, params?: unknown): void;
43
+ /**
44
+ * Write bytes verbatim. Used to test how the server handles garbage, and the
45
+ * single choke point for every unsolicited write, so a dead pipe is handled
46
+ * in exactly one place.
47
+ */
48
+ writeRaw(text: string): void;
49
+ isAlive(): boolean;
50
+ exitInfo(): {
51
+ code: number | null;
52
+ signal: string | null;
53
+ } | null;
54
+ close(): Promise<void>;
55
+ }
@@ -0,0 +1,213 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { TimeoutError, TransportClosedError } from './jsonrpc.js';
3
+ /**
4
+ * Newline-delimited JSON-RPC over a child process's stdio.
5
+ *
6
+ * Deliberately hand-rolled rather than built on the official SDK: the SDK
7
+ * discards anything it cannot parse, and the unparseable bytes are exactly
8
+ * what we are here to find. A single stray `console.log` in a server puts a
9
+ * non-JSON line on stdout, which corrupts the stream for every client that
10
+ * connects to it -- and the server author never sees an error.
11
+ */
12
+ export class StdioTransport {
13
+ opts;
14
+ kind = 'stdio';
15
+ target;
16
+ child = null;
17
+ buffer = '';
18
+ nextId = 1;
19
+ pending = new Map();
20
+ exited = null;
21
+ /** stdout lines that were not valid JSON. Almost always a logging bug. */
22
+ stdoutNoise = [];
23
+ stderr = [];
24
+ /** Pipe faults (EPIPE and friends) seen after the child went away. */
25
+ pipeErrors = [];
26
+ /** Notifications the server pushed at us, kept for later assertions. */
27
+ serverNotifications = [];
28
+ constructor(opts) {
29
+ this.opts = opts;
30
+ this.target = [opts.command, ...opts.args].join(' ');
31
+ }
32
+ async start() {
33
+ // On Windows, a bare command name often resolves to a .cmd shim -- npx,
34
+ // pnpm, yarn all do -- and CreateProcess cannot execute those directly, so
35
+ // they need a shell. A path to a real executable must NOT go through the
36
+ // shell, because cmd.exe splits it on spaces and
37
+ // "C:\Program Files\nodejs\node.exe" becomes "C:\Program".
38
+ const isWin = process.platform === 'win32';
39
+ const hasPathSeparator = /[\\/]/.test(this.opts.command);
40
+ const isExe = /\.(exe|com)$/i.test(this.opts.command);
41
+ const useShell = isWin && !hasPathSeparator && !isExe;
42
+ // When the shell is in play it re-parses the whole line, so anything
43
+ // containing whitespace has to carry its own quotes.
44
+ const quote = (s) => (useShell && /[\s"^&|<>]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s);
45
+ const child = spawn(quote(this.opts.command), this.opts.args.map(quote), {
46
+ stdio: ['pipe', 'pipe', 'pipe'],
47
+ env: { ...process.env, ...this.opts.env },
48
+ cwd: this.opts.cwd,
49
+ shell: useShell,
50
+ windowsVerbatimArguments: useShell,
51
+ });
52
+ this.child = child;
53
+ child.stdout.setEncoding('utf8');
54
+ child.stdout.on('data', (chunk) => this.onStdout(chunk));
55
+ child.stderr.setEncoding('utf8');
56
+ child.stderr.on('data', (chunk) => {
57
+ for (const line of chunk.split('\n'))
58
+ if (line.trim())
59
+ this.stderr.push(line);
60
+ });
61
+ // Writing to a pipe whose far end has gone emits EPIPE on the stream. With
62
+ // no listener Node promotes that to an uncaught exception, which would
63
+ // crash mcp-probe instead of reporting the dead server -- and the whole
64
+ // contract here is that a broken server produces a report, not a crash.
65
+ // Whether the write or the exit lands first is a race, so this shows up
66
+ // on some platforms and not others.
67
+ const swallow = (e) => {
68
+ this.pipeErrors.push(e.message);
69
+ };
70
+ child.stdin.on('error', swallow);
71
+ child.stdout.on('error', swallow);
72
+ child.stderr.on('error', swallow);
73
+ child.on('exit', (code, signal) => {
74
+ this.exited = { code, signal };
75
+ const err = new TransportClosedError(`Server exited (code ${code ?? 'null'}${signal ? `, signal ${signal}` : ''}) with ${this.pending.size} request(s) in flight`, code, this.stderr.slice(-20).join('\n'));
76
+ for (const [, p] of this.pending) {
77
+ clearTimeout(p.timer);
78
+ p.reject(err);
79
+ }
80
+ this.pending.clear();
81
+ });
82
+ await new Promise((resolve, reject) => {
83
+ const onError = (e) => reject(new TransportClosedError(`Failed to spawn \`${this.target}\`: ${e.message}`));
84
+ child.once('error', onError);
85
+ // Give spawn a tick to fail loudly; a server that dies later is caught
86
+ // by the in-flight rejection above.
87
+ setTimeout(() => {
88
+ child.off('error', onError);
89
+ resolve();
90
+ }, 50);
91
+ });
92
+ }
93
+ onStdout(chunk) {
94
+ this.buffer += chunk;
95
+ let idx;
96
+ while ((idx = this.buffer.indexOf('\n')) !== -1) {
97
+ const line = this.buffer.slice(0, idx).replace(/\r$/, '');
98
+ this.buffer = this.buffer.slice(idx + 1);
99
+ if (!line.trim())
100
+ continue;
101
+ this.consumeLine(line);
102
+ }
103
+ }
104
+ consumeLine(line) {
105
+ let msg;
106
+ try {
107
+ msg = JSON.parse(line);
108
+ }
109
+ catch {
110
+ // Not JSON. Record it and keep going -- we want the full list, not just
111
+ // the first one, so the report can show the author every offending line.
112
+ if (this.stdoutNoise.length < 50)
113
+ this.stdoutNoise.push(line);
114
+ return;
115
+ }
116
+ if (msg && typeof msg === 'object' && msg.id !== undefined && msg.id !== null) {
117
+ const waiter = this.pending.get(msg.id);
118
+ if (waiter) {
119
+ clearTimeout(waiter.timer);
120
+ this.pending.delete(msg.id);
121
+ waiter.resolve(msg);
122
+ return;
123
+ }
124
+ }
125
+ // No id, or an id nobody is waiting on: a notification or a stray reply.
126
+ this.serverNotifications.push(msg);
127
+ }
128
+ request(method, params, timeoutMs = 10_000) {
129
+ const id = this.nextId++;
130
+ return this.send({ jsonrpc: '2.0', id, method, ...(params !== undefined ? { params } : {}) }, id, method, timeoutMs);
131
+ }
132
+ /** Send a request with a caller-chosen id/shape, for malformed-input probes. */
133
+ requestRaw(payload, id, method, timeoutMs = 10_000) {
134
+ return this.send(payload, id, method, timeoutMs);
135
+ }
136
+ send(payload, id, method, timeoutMs) {
137
+ if (!this.child || this.exited) {
138
+ return Promise.reject(new TransportClosedError(`Server is not running (exit code ${this.exited?.code ?? 'unknown'})`, this.exited?.code ?? null, this.stderr.slice(-20).join('\n')));
139
+ }
140
+ return new Promise((resolve, reject) => {
141
+ const timer = setTimeout(() => {
142
+ this.pending.delete(id);
143
+ reject(new TimeoutError(method, timeoutMs));
144
+ }, timeoutMs);
145
+ this.pending.set(id, { resolve, reject, timer });
146
+ const failWrite = (message) => {
147
+ clearTimeout(timer);
148
+ this.pending.delete(id);
149
+ reject(new TransportClosedError(`Write to stdin failed: ${message}`));
150
+ };
151
+ // write() reports asynchronously through the callback, but it can also
152
+ // throw synchronously once the stream is destroyed.
153
+ try {
154
+ this.child.stdin.write(JSON.stringify(payload) + '\n', (err) => {
155
+ if (err)
156
+ failWrite(err.message);
157
+ });
158
+ }
159
+ catch (e) {
160
+ failWrite(e.message);
161
+ }
162
+ });
163
+ }
164
+ notify(method, params) {
165
+ this.writeRaw(JSON.stringify({ jsonrpc: '2.0', method, ...(params !== undefined ? { params } : {}) }) + '\n');
166
+ }
167
+ /**
168
+ * Write bytes verbatim. Used to test how the server handles garbage, and the
169
+ * single choke point for every unsolicited write, so a dead pipe is handled
170
+ * in exactly one place.
171
+ */
172
+ writeRaw(text) {
173
+ if (!this.child || this.exited || !this.child.stdin.writable)
174
+ return;
175
+ try {
176
+ this.child.stdin.write(text);
177
+ }
178
+ catch (e) {
179
+ // The child can exit between the liveness check and the write landing.
180
+ this.pipeErrors.push(e.message);
181
+ }
182
+ }
183
+ isAlive() {
184
+ return this.child !== null && this.exited === null;
185
+ }
186
+ exitInfo() {
187
+ return this.exited;
188
+ }
189
+ async close() {
190
+ for (const [, p] of this.pending)
191
+ clearTimeout(p.timer);
192
+ this.pending.clear();
193
+ const child = this.child;
194
+ if (!child || this.exited)
195
+ return;
196
+ try {
197
+ child.stdin.end();
198
+ }
199
+ catch {
200
+ /* Pipe already torn down; there is nothing left to close. */
201
+ }
202
+ await new Promise((resolve) => {
203
+ const t = setTimeout(() => {
204
+ child.kill('SIGKILL');
205
+ resolve();
206
+ }, 1500);
207
+ child.once('exit', () => {
208
+ clearTimeout(t);
209
+ resolve();
210
+ });
211
+ });
212
+ }
213
+ }
@@ -0,0 +1,21 @@
1
+ import type { JsonRpcResponse } from './jsonrpc.js';
2
+ /** The surface the checks are written against, so they work over stdio or HTTP. */
3
+ export interface Transport {
4
+ readonly kind: 'stdio' | 'http';
5
+ readonly target: string;
6
+ /** stdout lines that were not valid JSON (stdio only; empty for HTTP). */
7
+ readonly stdoutNoise: string[];
8
+ readonly stderr: string[];
9
+ readonly serverNotifications: JsonRpcResponse[];
10
+ start(): Promise<void>;
11
+ request(method: string, params?: unknown, timeoutMs?: number): Promise<JsonRpcResponse>;
12
+ requestRaw(payload: Record<string, unknown>, id: number | string, method: string, timeoutMs?: number): Promise<JsonRpcResponse>;
13
+ notify(method: string, params?: unknown): void;
14
+ writeRaw(text: string): void;
15
+ isAlive(): boolean;
16
+ exitInfo(): {
17
+ code: number | null;
18
+ signal: string | null;
19
+ } | null;
20
+ close(): Promise<void>;
21
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ import type { ServerSpec } from './types.js';
2
+ /**
3
+ * Every place the well-known hosts keep their MCP configuration. Two shapes
4
+ * exist in the wild: `mcpServers` (Claude Desktop, Claude Code, Cursor,
5
+ * Windsurf) and `servers` (VS Code).
6
+ */
7
+ export declare function knownConfigPaths(platform?: NodeJS.Platform, home?: string, cwd?: string): Array<{
8
+ path: string;
9
+ host: string;
10
+ }>;
11
+ /** Pull every server out of one config file. Returns [] when unreadable. */
12
+ export declare function readConfigFile(path: string, host: string): ServerSpec[];
13
+ /**
14
+ * Search every known location, merge duplicates (the same server configured
15
+ * in several hosts is one server with several sources), and report which
16
+ * config files were actually found.
17
+ */
18
+ export declare function discover(explicitConfig?: string): {
19
+ specs: ServerSpec[];
20
+ configsSearched: string[];
21
+ };
@@ -0,0 +1,126 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ /**
5
+ * Every place the well-known hosts keep their MCP configuration. Two shapes
6
+ * exist in the wild: `mcpServers` (Claude Desktop, Claude Code, Cursor,
7
+ * Windsurf) and `servers` (VS Code).
8
+ */
9
+ export function knownConfigPaths(platform = process.platform, home = homedir(), cwd = process.cwd()) {
10
+ const paths = [];
11
+ const push = (path, host) => paths.push({ path, host });
12
+ if (platform === 'win32') {
13
+ const appdata = process.env['APPDATA'] ?? join(home, 'AppData', 'Roaming');
14
+ push(join(appdata, 'Claude', 'claude_desktop_config.json'), 'Claude Desktop');
15
+ push(join(appdata, 'Code', 'User', 'mcp.json'), 'VS Code');
16
+ }
17
+ else if (platform === 'darwin') {
18
+ push(join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'), 'Claude Desktop');
19
+ push(join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'), 'VS Code');
20
+ }
21
+ else {
22
+ push(join(home, '.config', 'Claude', 'claude_desktop_config.json'), 'Claude Desktop');
23
+ push(join(home, '.config', 'Code', 'User', 'mcp.json'), 'VS Code');
24
+ }
25
+ push(join(home, '.claude.json'), 'Claude Code');
26
+ push(join(cwd, '.mcp.json'), 'Claude Code (project)');
27
+ push(join(home, '.cursor', 'mcp.json'), 'Cursor');
28
+ push(join(cwd, '.cursor', 'mcp.json'), 'Cursor (project)');
29
+ push(join(home, '.codeium', 'windsurf', 'mcp_config.json'), 'Windsurf');
30
+ push(join(cwd, '.vscode', 'mcp.json'), 'VS Code (workspace)');
31
+ return paths;
32
+ }
33
+ function toSpec(name, raw, source) {
34
+ if (raw.disabled === true)
35
+ return null;
36
+ const sources = [source];
37
+ const url = typeof raw.url === 'string' ? raw.url : typeof raw.serverUrl === 'string' ? raw.serverUrl : null;
38
+ if (url) {
39
+ return { name, kind: 'http', url, headers: raw.headers ?? {}, sources };
40
+ }
41
+ if (typeof raw.command !== 'string' || !raw.command)
42
+ return null;
43
+ const args = Array.isArray(raw.args) ? raw.args.filter((a) => typeof a === 'string') : [];
44
+ const spec = {
45
+ name,
46
+ kind: 'stdio',
47
+ command: raw.command,
48
+ args,
49
+ env: raw.env ?? {},
50
+ cwd: typeof raw.cwd === 'string' ? raw.cwd : undefined,
51
+ sources,
52
+ };
53
+ // VS Code configs can reference interactive inputs (${input:apiKey}); those
54
+ // servers cannot be launched non-interactively, but they should still show
55
+ // up in the report rather than silently vanish.
56
+ const joined = [raw.command, ...args, JSON.stringify(spec.env)].join(' ');
57
+ if (joined.includes('${input:')) {
58
+ spec.unlaunchable = 'uses ${input:...} placeholders that need interactive values';
59
+ }
60
+ return spec;
61
+ }
62
+ /** Pull every server out of one config file. Returns [] when unreadable. */
63
+ export function readConfigFile(path, host) {
64
+ let parsed;
65
+ try {
66
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
67
+ }
68
+ catch {
69
+ return [];
70
+ }
71
+ const out = [];
72
+ const source = `${host} (${path})`;
73
+ const collect = (block) => {
74
+ if (!block || typeof block !== 'object' || Array.isArray(block))
75
+ return;
76
+ for (const [name, entry] of Object.entries(block)) {
77
+ const spec = toSpec(name, entry ?? {}, source);
78
+ if (spec)
79
+ out.push(spec);
80
+ }
81
+ };
82
+ collect(parsed['mcpServers']);
83
+ collect(parsed['servers']);
84
+ // Claude Code also nests per-project servers under `projects`.
85
+ const projects = parsed['projects'];
86
+ if (projects && typeof projects === 'object' && !Array.isArray(projects)) {
87
+ for (const proj of Object.values(projects)) {
88
+ collect(proj?.['mcpServers']);
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+ /**
94
+ * Search every known location, merge duplicates (the same server configured
95
+ * in several hosts is one server with several sources), and report which
96
+ * config files were actually found.
97
+ */
98
+ export function discover(explicitConfig) {
99
+ const candidates = explicitConfig
100
+ ? [{ path: explicitConfig, host: 'config' }]
101
+ : knownConfigPaths();
102
+ const configsSearched = [];
103
+ const byIdentity = new Map();
104
+ for (const { path, host } of candidates) {
105
+ if (!existsSync(path))
106
+ continue;
107
+ configsSearched.push(path);
108
+ for (const spec of readConfigFile(path, host)) {
109
+ // Identity is what would actually run, not the display name -- two hosts
110
+ // pointing at the same command are one server. The environment is part
111
+ // of what runs: the same command with a different env (one with a real
112
+ // token, one with a placeholder) is a different server, and merging
113
+ // them would silently drop one from the report.
114
+ const envKey = JSON.stringify(Object.entries(spec.env ?? {}).sort());
115
+ const identity = spec.kind === 'http'
116
+ ? `http|${spec.url}`
117
+ : `stdio|${spec.command}|${(spec.args ?? []).join(' ')}|${envKey}|${spec.cwd ?? ''}`;
118
+ const existing = byIdentity.get(identity);
119
+ if (existing)
120
+ existing.sources.push(...spec.sources);
121
+ else
122
+ byIdentity.set(identity, spec);
123
+ }
124
+ }
125
+ return { specs: [...byIdentity.values()], configsSearched };
126
+ }
@@ -0,0 +1,21 @@
1
+ import type { ToolDef } from './types.js';
2
+ /**
3
+ * Serialise a tool the way hosts actually send it to the model. The wire
4
+ * field is `input_schema`; measuring the camelCase MCP field would count the
5
+ * wrong bytes.
6
+ */
7
+ export declare function wireFormat(tool: ToolDef): Record<string, unknown>;
8
+ /**
9
+ * Keyless token estimate. Modern tokenizers average a little under 4
10
+ * characters per token on English prose but much less on JSON, which is dense
11
+ * in punctuation and braces that tokenize one-per-character. Weighting the two
12
+ * classes separately keeps the estimate honest across schema-heavy payloads
13
+ * without shipping a tokenizer.
14
+ */
15
+ export declare function estimateTokens(text: string): number;
16
+ /**
17
+ * Ground-truth measurement: ask the Anthropic token counter what the request
18
+ * costs with the tools attached, subtract what it costs without them. The
19
+ * counting endpoint is free of charge, so --precise costs nothing to run.
20
+ */
21
+ export declare function countToolTokens(apiKey: string, tools: ToolDef[]): Promise<number>;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Serialise a tool the way hosts actually send it to the model. The wire
3
+ * field is `input_schema`; measuring the camelCase MCP field would count the
4
+ * wrong bytes.
5
+ */
6
+ export function wireFormat(tool) {
7
+ return {
8
+ name: tool.name,
9
+ description: tool.description ?? '',
10
+ input_schema: tool.inputSchema ?? { type: 'object' },
11
+ };
12
+ }
13
+ /**
14
+ * Keyless token estimate. Modern tokenizers average a little under 4
15
+ * characters per token on English prose but much less on JSON, which is dense
16
+ * in punctuation and braces that tokenize one-per-character. Weighting the two
17
+ * classes separately keeps the estimate honest across schema-heavy payloads
18
+ * without shipping a tokenizer.
19
+ */
20
+ export function estimateTokens(text) {
21
+ if (!text)
22
+ return 0;
23
+ let structural = 0;
24
+ for (let i = 0; i < text.length; i++) {
25
+ const c = text.charCodeAt(i);
26
+ // Punctuation and symbols: {}[]":, etc. tokenize far denser than prose.
27
+ if ((c >= 0x21 && c <= 0x2f) ||
28
+ (c >= 0x3a && c <= 0x40) ||
29
+ (c >= 0x5b && c <= 0x60) ||
30
+ (c >= 0x7b && c <= 0x7e)) {
31
+ structural++;
32
+ }
33
+ }
34
+ const prose = text.length - structural;
35
+ return Math.max(1, Math.round(prose / 4.2 + structural / 1.8));
36
+ }
37
+ const API_URL = 'https://api.anthropic.com/v1/messages/count_tokens';
38
+ const COUNT_MODEL = 'claude-sonnet-5';
39
+ async function countTokensCall(apiKey, tools) {
40
+ const res = await fetch(API_URL, {
41
+ method: 'POST',
42
+ headers: {
43
+ 'content-type': 'application/json',
44
+ 'x-api-key': apiKey,
45
+ 'anthropic-version': '2023-06-01',
46
+ },
47
+ body: JSON.stringify({
48
+ model: COUNT_MODEL,
49
+ messages: [{ role: 'user', content: 'hi' }],
50
+ ...(tools.length > 0 ? { tools } : {}),
51
+ }),
52
+ });
53
+ if (!res.ok) {
54
+ throw new Error(`count_tokens returned HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
55
+ }
56
+ const body = (await res.json());
57
+ if (typeof body.input_tokens !== 'number')
58
+ throw new Error('count_tokens response had no input_tokens');
59
+ return body.input_tokens;
60
+ }
61
+ /**
62
+ * Ground-truth measurement: ask the Anthropic token counter what the request
63
+ * costs with the tools attached, subtract what it costs without them. The
64
+ * counting endpoint is free of charge, so --precise costs nothing to run.
65
+ */
66
+ export async function countToolTokens(apiKey, tools) {
67
+ const [withTools, baseline] = await Promise.all([
68
+ countTokensCall(apiKey, tools.map(wireFormat)),
69
+ countTokensCall(apiKey, []),
70
+ ]);
71
+ return Math.max(0, withTools - baseline);
72
+ }
@@ -0,0 +1,6 @@
1
+ export { discover, readConfigFile, knownConfigPaths } from './discover.js';
2
+ export { weighServer, weighAll } from './weigh.js';
3
+ export { estimateTokens, countToolTokens, wireFormat } from './estimate.js';
4
+ export { renderTerminal } from './report/terminal.js';
5
+ export { McpClient, StdioTransport, HttpTransport } from './client/index.js';
6
+ export type { JsonSchema, ServerSpec, ServerWeight, ToolDef, ToolWeight, XrayOptions, XrayReport, } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { discover, readConfigFile, knownConfigPaths } from './discover.js';
2
+ export { weighServer, weighAll } from './weigh.js';
3
+ export { estimateTokens, countToolTokens, wireFormat } from './estimate.js';
4
+ export { renderTerminal } from './report/terminal.js';
5
+ export { McpClient, StdioTransport, HttpTransport } from './client/index.js';
@@ -0,0 +1,2 @@
1
+ import type { XrayReport } from '../types.js';
2
+ export declare function renderTerminal(report: XrayReport, top?: number): string;