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.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +224 -0
- package/dist/client/http.d.ts +37 -0
- package/dist/client/http.js +133 -0
- package/dist/client/index.d.ts +47 -0
- package/dist/client/index.js +79 -0
- package/dist/client/jsonrpc.d.ts +40 -0
- package/dist/client/jsonrpc.js +28 -0
- package/dist/client/stdio.d.ts +55 -0
- package/dist/client/stdio.js +213 -0
- package/dist/client/transport.d.ts +21 -0
- package/dist/client/transport.js +1 -0
- package/dist/discover.d.ts +21 -0
- package/dist/discover.js +126 -0
- package/dist/estimate.d.ts +21 -0
- package/dist/estimate.js +72 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/report/terminal.d.ts +2 -0
- package/dist/report/terminal.js +105 -0
- package/dist/types.d.ts +88 -0
- package/dist/types.js +1 -0
- package/dist/weigh.d.ts +5 -0
- package/dist/weigh.js +126 -0
- package/package.json +63 -0
|
@@ -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
|
+
};
|
package/dist/discover.js
ADDED
|
@@ -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>;
|
package/dist/estimate.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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';
|