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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BenYang
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 ADDED
@@ -0,0 +1,127 @@
1
+ # context-xray
2
+
3
+ **See what your MCP servers cost you — before you type a word.**
4
+
5
+ [![CI](https://github.com/Beeeeen/context-xray/actions/workflows/ci.yml/badge.svg)](https://github.com/Beeeeen/context-xray/actions/workflows/ci.yml)
6
+ [![npm](https://img.shields.io/npm/v/context-xray.svg)](https://www.npmjs.com/package/context-xray)
7
+ [![license](https://img.shields.io/npm/l/context-xray.svg)](./LICENSE)
8
+
9
+ ```bash
10
+ npx context-xray
11
+ ```
12
+
13
+ Every MCP server you configure injects its full tool catalog — every name, every description, every JSON schema — into **every single request** your agent makes. You never see this. It happens before your first word, it is resent on every message, and it never shows up itemised on a bill.
14
+
15
+ context-xray finds every server configured in Claude Desktop, Claude Code, Cursor, Windsurf and VS Code, connects to each one, and weighs exactly what it charges you:
16
+
17
+ ```
18
+ context-xray your MCP servers add ~14,443 tokens to every request
19
+ 1 config searched, 4 servers found, 4 measured
20
+
21
+ rank server host tools tokens share
22
+ #1 github Claude Code 26 ~5,123 35% ######............
23
+ #2 playwright Claude Code 24 ~4,785 33% ######............
24
+ #3 filesystem Claude Desktop 14 ~2,333 16% ###...............
25
+ #4 everything Claude Desktop 16 ~2,202 15% ###...............
26
+
27
+ github github-mcp-server v0.6.2 26 tools, connected in 3523ms
28
+ 513 tok create_pull_request_review desc 8, schema 482
29
+ 326 tok list_pull_requests desc 10, schema 296
30
+ 292 tok create_pull_request desc 11, schema 259
31
+ 3,992 tok … 23 more tools
32
+
33
+ playwright Playwright v1.63.0 24 tools, connected in 3900ms
34
+ 429 tok browser_take_screenshot desc 31, schema 376
35
+ 324 tok browser_fill_form desc 6, schema 297
36
+ 4,032 tok … 22 more tools
37
+
38
+ ------------------------------------------------------------------------
39
+ ~14,443 tokens on every request = 7.2% of a 200,000 context window, before you type a word
40
+ at 200 requests/day and $3.00/MTok input: ~$260/month of uncached input spend
41
+
42
+ ! over 10% of the window goes to tool definitions -- disable the servers
43
+ you are not using today
44
+ ```
45
+
46
+ Those are real numbers from real servers — that four-server setup is a perfectly ordinary one, and it spends 7% of the context window on standby.
47
+
48
+ No install, no config, no account, zero dependencies. It reads the configs you already have.
49
+
50
+ ---
51
+
52
+ ## Why this matters
53
+
54
+ **Context.** The context window is the scarcest resource an agent has. Tool definitions are pure overhead: they crowd out your conversation, your files, your actual work. Long sessions degrade sooner, and "compact" happens earlier, in direct proportion to this number. Most people have never seen it.
55
+
56
+ **Money.** Input tokens are billed per request. A 14k-token tool catalog at a few hundred requests a day is real spend — caching softens it but does not erase it, and cache writes bill at a premium. The table above is the line item your invoice never shows.
57
+
58
+ **Model quality.** Models pick tools by reading their descriptions. A 24-tool server whose every schema looks alike does not just cost tokens — it measurably degrades tool selection. The heaviest tools in the ranking are usually also the ones confusing your agent.
59
+
60
+ ## Usage
61
+
62
+ ```bash
63
+ # find and weigh everything configured on this machine
64
+ npx context-xray
65
+
66
+ # just one host's config, or one server
67
+ npx context-xray --config ~/.claude.json
68
+ npx context-xray --server github,playwright
69
+
70
+ # weigh a server that is not configured anywhere yet
71
+ npx context-xray -- npx -y @playwright/mcp@latest
72
+ npx context-xray --url http://localhost:3000/mcp
73
+ ```
74
+
75
+ | flag | |
76
+ |---|---|
77
+ | `--precise` | exact counts via the free Anthropic `count_tokens` API (needs `ANTHROPIC_API_KEY`) |
78
+ | `--json` | machine-readable report |
79
+ | `--requests-per-day <n>` | volume assumption for the cost line (default 200) |
80
+ | `--price <usd>` | $/MTok input for the cost line (default 3.00) |
81
+ | `--top <n>` | tools listed per server (default 3) |
82
+ | `--timeout <ms>` | per-server timeout (default 15000) |
83
+
84
+ Configs it knows how to read: Claude Desktop, Claude Code (`~/.claude.json`, per-project entries, and `./.mcp.json`), Cursor, Windsurf, and VS Code (both the `mcpServers` and `servers` shapes, including `${input:...}` entries — those are reported as unmeasurable rather than silently skipped). A server configured in several hosts is measured once and attributed to all of them.
85
+
86
+ ## Exact numbers
87
+
88
+ By default the token counts are estimates from a tokenizer-calibrated character model (JSON weighs heavier than prose, and is marked `~`). For exact numbers:
89
+
90
+ ```bash
91
+ ANTHROPIC_API_KEY=sk-... npx context-xray --precise
92
+ ```
93
+
94
+ This uses the Anthropic [count_tokens](https://docs.anthropic.com/en/api/messages-count-tokens) endpoint, which is free of charge. Only the tool definitions being counted are sent — never your conversation, never your files.
95
+
96
+ ## What it does and does not do
97
+
98
+ - It connects, performs the MCP handshake, reads the tool/resource/prompt lists, and disconnects. **It never invokes a tool.**
99
+ - Environment values in your configs are passed to the servers they belong to, and are **never printed or transmitted**.
100
+ - Servers that fail to start are reported with the reason (and their stderr), not skipped — a server that cannot start is costing you a different way.
101
+ - The measured tax covers what hosts inject per request: tool definitions plus server `instructions`. Resources and prompts are listed for information but are not part of the per-request tax.
102
+
103
+ ## Programmatic use
104
+
105
+ ```ts
106
+ import { discover, weighAll } from 'context-xray'
107
+
108
+ const { specs } = discover()
109
+ const weights = await weighAll(specs, {
110
+ timeoutMs: 15000, precise: false, requestsPerDay: 200,
111
+ pricePerMTok: 3, top: 3, concurrency: 4,
112
+ })
113
+ for (const w of weights) console.log(w.spec.name, w.taxTokens)
114
+ ```
115
+
116
+ ## See also
117
+
118
+ The rest of the toolchain, built on the same zero-dependency MCP client:
119
+
120
+ - [**mcp-wtf**](https://github.com/Beeeeen/mcp-wtf) — your MCP server won't connect; find out why in 10 seconds.
121
+ - [**mcp-probe**](https://github.com/Beeeeen/mcp-probe) — conformance and robustness tests for MCP servers, built to run in CI.
122
+
123
+ mcp-wtf answers "why won't it connect", context-xray answers "what is it costing me", mcp-probe answers "will it break my users".
124
+
125
+ ## License
126
+
127
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from 'node:fs';
3
+ import { discover } from './discover.js';
4
+ import { weighAll } from './weigh.js';
5
+ import { renderTerminal } from './report/terminal.js';
6
+ const VERSION = '0.1.0';
7
+ const HELP = `
8
+ context-xray ${VERSION}
9
+ See what your MCP servers cost you: the context-window tokens they add to
10
+ every single request, before you type a word.
11
+
12
+ USAGE
13
+ context-xray find and weigh every configured server
14
+ context-xray --config <file> weigh the servers in one config file
15
+ context-xray --server <name> only the named server(s); repeatable
16
+ context-xray -- <command> [...] weigh one stdio server directly
17
+ context-xray --url <url> weigh one streamable-HTTP server
18
+
19
+ OUTPUT
20
+ --json machine-readable report on stdout
21
+ --top <n> tools to list per server (default 3)
22
+
23
+ MEASUREMENT
24
+ --precise use the free Anthropic count_tokens API for exact
25
+ numbers (needs ANTHROPIC_API_KEY; nothing is sent
26
+ anywhere except the tool definitions being counted)
27
+ --timeout <ms> per-server timeout (default 15000)
28
+ --concurrency <n> servers weighed at once (default 4)
29
+
30
+ COST PROJECTION
31
+ --requests-per-day <n> assumed request volume (default 200)
32
+ --price <usd> $ per million input tokens (default 3.00)
33
+
34
+ Configs searched: Claude Desktop, Claude Code (~/.claude.json and ./.mcp.json),
35
+ Cursor, Windsurf, VS Code. Servers appearing in several hosts are measured once.
36
+
37
+ context-xray never calls your tools. It connects, reads the tool list, and
38
+ disconnects. Environment values from your configs are passed to the servers
39
+ they belong to and are never printed.
40
+ `;
41
+ function parseArgs(argv) {
42
+ const out = {
43
+ options: {
44
+ timeoutMs: 15_000,
45
+ precise: false,
46
+ apiKey: process.env['ANTHROPIC_API_KEY'],
47
+ requestsPerDay: 200,
48
+ pricePerMTok: 3,
49
+ top: 3,
50
+ concurrency: 4,
51
+ },
52
+ json: false,
53
+ top: 3,
54
+ serverFilter: [],
55
+ help: false,
56
+ version: false,
57
+ };
58
+ let url = null;
59
+ let headers = {};
60
+ for (let i = 0; i < argv.length; i++) {
61
+ const arg = argv[i];
62
+ if (arg === '--') {
63
+ const rest = argv.slice(i + 1);
64
+ if (rest.length === 0)
65
+ return { ...out, error: '`--` must be followed by the server command' };
66
+ out.direct = { name: rest.join(' '), kind: 'stdio', command: rest[0], args: rest.slice(1), sources: ['command line'] };
67
+ break;
68
+ }
69
+ const next = () => argv[++i];
70
+ const num = (flag) => {
71
+ const v = Number(next());
72
+ if (!Number.isFinite(v) || v <= 0) {
73
+ out.error = `${flag} needs a positive number`;
74
+ return null;
75
+ }
76
+ return v;
77
+ };
78
+ switch (arg) {
79
+ case '-h':
80
+ case '--help':
81
+ out.help = true;
82
+ break;
83
+ case '-v':
84
+ case '--version':
85
+ out.version = true;
86
+ break;
87
+ case '--json':
88
+ out.json = true;
89
+ break;
90
+ case '--precise':
91
+ out.options.precise = true;
92
+ break;
93
+ case '--config':
94
+ out.config = next();
95
+ break;
96
+ case '--server':
97
+ out.serverFilter.push(...(next() ?? '').split(',').filter(Boolean));
98
+ break;
99
+ case '--url':
100
+ url = next() ?? null;
101
+ break;
102
+ case '--header': {
103
+ const raw = next() ?? '';
104
+ const idx = raw.indexOf(':');
105
+ if (idx < 1)
106
+ return { ...out, error: `--header expects "Name: value", got "${raw}"` };
107
+ headers[raw.slice(0, idx).trim()] = raw.slice(idx + 1).trim();
108
+ break;
109
+ }
110
+ case '--top': {
111
+ const v = num('--top');
112
+ if (v === null)
113
+ return out;
114
+ out.top = Math.floor(v);
115
+ break;
116
+ }
117
+ case '--timeout': {
118
+ const v = num('--timeout');
119
+ if (v === null)
120
+ return out;
121
+ out.options.timeoutMs = v;
122
+ break;
123
+ }
124
+ case '--concurrency': {
125
+ const v = num('--concurrency');
126
+ if (v === null)
127
+ return out;
128
+ out.options.concurrency = Math.floor(v);
129
+ break;
130
+ }
131
+ case '--requests-per-day': {
132
+ const v = num('--requests-per-day');
133
+ if (v === null)
134
+ return out;
135
+ out.options.requestsPerDay = v;
136
+ break;
137
+ }
138
+ case '--price': {
139
+ const v = num('--price');
140
+ if (v === null)
141
+ return out;
142
+ out.options.pricePerMTok = v;
143
+ break;
144
+ }
145
+ default:
146
+ return { ...out, error: `Unknown option "${arg}". Try --help.` };
147
+ }
148
+ }
149
+ if (url) {
150
+ out.direct = { name: url, kind: 'http', url, headers, sources: ['command line'] };
151
+ }
152
+ if (out.options.precise && !out.options.apiKey) {
153
+ return { ...out, error: '--precise needs ANTHROPIC_API_KEY in the environment' };
154
+ }
155
+ return out;
156
+ }
157
+ async function main() {
158
+ const parsed = parseArgs(process.argv.slice(2));
159
+ if (parsed.help) {
160
+ process.stdout.write(HELP);
161
+ process.exit(0);
162
+ }
163
+ if (parsed.version) {
164
+ process.stdout.write(VERSION + '\n');
165
+ process.exit(0);
166
+ }
167
+ if (parsed.error) {
168
+ process.stderr.write(`context-xray: ${parsed.error}\n`);
169
+ process.exit(2);
170
+ }
171
+ let specs;
172
+ let configsSearched;
173
+ if (parsed.direct) {
174
+ specs = [parsed.direct];
175
+ configsSearched = [];
176
+ }
177
+ else {
178
+ if (parsed.config && !existsSync(parsed.config)) {
179
+ process.stderr.write(`context-xray: no such config file: ${parsed.config}\n`);
180
+ process.exit(2);
181
+ }
182
+ const found = discover(parsed.config);
183
+ specs = found.specs;
184
+ configsSearched = found.configsSearched;
185
+ if (parsed.serverFilter.length > 0) {
186
+ specs = specs.filter((s) => parsed.serverFilter.includes(s.name));
187
+ const missing = parsed.serverFilter.filter((f) => !specs.some((s) => s.name === f));
188
+ if (missing.length > 0) {
189
+ process.stderr.write(`context-xray: no server named ${missing.map((m) => `"${m}"`).join(', ')} in the discovered configs\n`);
190
+ process.exit(2);
191
+ }
192
+ }
193
+ }
194
+ if (specs.length === 0) {
195
+ process.stderr.write(configsSearched.length === 0
196
+ ? 'context-xray: no MCP config files found. Point it at one with --config <file>, or at a server with `context-xray -- <command>`.\n'
197
+ : `context-xray: searched ${configsSearched.length} config file(s) but found no MCP servers in them.\n`);
198
+ process.exit(2);
199
+ }
200
+ const t0 = Date.now();
201
+ const servers = await weighAll(specs, parsed.options);
202
+ const measured = servers.filter((s) => s.ok);
203
+ const methods = new Set(measured.map((s) => s.method));
204
+ const report = {
205
+ servers,
206
+ totalTaxTokens: measured.reduce((sum, s) => sum + s.taxTokens, 0),
207
+ method: methods.size === 1 ? (methods.has('counted') ? 'counted' : 'estimate') : methods.size === 0 ? 'estimate' : 'mixed',
208
+ options: { requestsPerDay: parsed.options.requestsPerDay, pricePerMTok: parsed.options.pricePerMTok },
209
+ configsSearched,
210
+ durationMs: Date.now() - t0,
211
+ };
212
+ if (parsed.json) {
213
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
214
+ }
215
+ else {
216
+ process.stdout.write(renderTerminal(report, parsed.top));
217
+ }
218
+ // 0 when everything asked for was measured; 1 when some servers failed.
219
+ process.exit(measured.length === servers.length ? 0 : 1);
220
+ }
221
+ main().catch((e) => {
222
+ process.stderr.write(`context-xray: internal error: ${e.stack ?? String(e)}\n`);
223
+ process.exit(2);
224
+ });
@@ -0,0 +1,37 @@
1
+ import type { Transport } from './transport.js';
2
+ import { type JsonRpcResponse } from './jsonrpc.js';
3
+ export interface HttpOptions {
4
+ url: string;
5
+ headers?: Record<string, string>;
6
+ }
7
+ /**
8
+ * Streamable HTTP transport. Each request is a POST; the server may answer
9
+ * with `application/json` or an SSE stream, and may hand us a session id on
10
+ * the initialize response that must be echoed on every later call.
11
+ */
12
+ export declare class HttpTransport implements Transport {
13
+ private opts;
14
+ readonly kind = "http";
15
+ readonly target: string;
16
+ readonly stdoutNoise: string[];
17
+ readonly stderr: string[];
18
+ readonly serverNotifications: JsonRpcResponse[];
19
+ private nextId;
20
+ private sessionId;
21
+ private closed;
22
+ /** Populated when a response arrives with a shape we could not read. */
23
+ readonly protocolNotes: string[];
24
+ constructor(opts: HttpOptions);
25
+ start(): Promise<void>;
26
+ private headers;
27
+ request(method: string, params?: unknown, timeoutMs?: number): Promise<JsonRpcResponse>;
28
+ requestRaw(payload: Record<string, unknown>, _id: number | string, method: string, timeoutMs?: number): Promise<JsonRpcResponse>;
29
+ private post;
30
+ /** Pull the first `data:` frame that carries a JSON-RPC reply. */
31
+ private parseSse;
32
+ notify(method: string, params?: unknown): void;
33
+ writeRaw(text: string): void;
34
+ isAlive(): boolean;
35
+ exitInfo(): null;
36
+ close(): Promise<void>;
37
+ }
@@ -0,0 +1,133 @@
1
+ import { TimeoutError, TransportClosedError } from './jsonrpc.js';
2
+ /**
3
+ * Streamable HTTP transport. Each request is a POST; the server may answer
4
+ * with `application/json` or an SSE stream, and may hand us a session id on
5
+ * the initialize response that must be echoed on every later call.
6
+ */
7
+ export class HttpTransport {
8
+ opts;
9
+ kind = 'http';
10
+ target;
11
+ stdoutNoise = [];
12
+ stderr = [];
13
+ serverNotifications = [];
14
+ nextId = 1;
15
+ sessionId = null;
16
+ closed = false;
17
+ /** Populated when a response arrives with a shape we could not read. */
18
+ protocolNotes = [];
19
+ constructor(opts) {
20
+ this.opts = opts;
21
+ this.target = opts.url;
22
+ }
23
+ async start() {
24
+ /* Nothing to spawn; the first POST is the real connection test. */
25
+ }
26
+ headers() {
27
+ const h = {
28
+ 'content-type': 'application/json',
29
+ accept: 'application/json, text/event-stream',
30
+ ...this.opts.headers,
31
+ };
32
+ if (this.sessionId)
33
+ h['mcp-session-id'] = this.sessionId;
34
+ return h;
35
+ }
36
+ request(method, params, timeoutMs = 10_000) {
37
+ const id = this.nextId++;
38
+ return this.post({ jsonrpc: '2.0', id, method, ...(params !== undefined ? { params } : {}) }, method, timeoutMs);
39
+ }
40
+ requestRaw(payload, _id, method, timeoutMs = 10_000) {
41
+ return this.post(payload, method, timeoutMs);
42
+ }
43
+ async post(payload, method, timeoutMs) {
44
+ if (this.closed)
45
+ throw new TransportClosedError('Transport already closed');
46
+ const ac = new AbortController();
47
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
48
+ let res;
49
+ try {
50
+ res = await fetch(this.opts.url, {
51
+ method: 'POST',
52
+ headers: this.headers(),
53
+ body: JSON.stringify(payload),
54
+ signal: ac.signal,
55
+ });
56
+ }
57
+ catch (e) {
58
+ clearTimeout(timer);
59
+ if (ac.signal.aborted)
60
+ throw new TimeoutError(method, timeoutMs);
61
+ throw new TransportClosedError(`POST ${this.opts.url} failed: ${e.message}`);
62
+ }
63
+ clearTimeout(timer);
64
+ const sid = res.headers.get('mcp-session-id');
65
+ if (sid)
66
+ this.sessionId = sid;
67
+ // 202 with no body is the legal answer to a notification.
68
+ if (res.status === 202)
69
+ return {};
70
+ const ctype = res.headers.get('content-type') ?? '';
71
+ const body = await res.text();
72
+ if (!res.ok) {
73
+ throw new TransportClosedError(`HTTP ${res.status} ${res.statusText} from ${this.opts.url}: ${body.slice(0, 400)}`);
74
+ }
75
+ if (ctype.includes('text/event-stream'))
76
+ return this.parseSse(body, method);
77
+ if (!body.trim())
78
+ return {};
79
+ try {
80
+ return JSON.parse(body);
81
+ }
82
+ catch {
83
+ this.protocolNotes.push(`Non-JSON body for \`${method}\` (content-type: ${ctype || 'none'}): ${body.slice(0, 200)}`);
84
+ throw new TransportClosedError(`Server returned unparseable body for \`${method}\``);
85
+ }
86
+ }
87
+ /** Pull the first `data:` frame that carries a JSON-RPC reply. */
88
+ parseSse(body, method) {
89
+ const frames = body.split(/\n\n/);
90
+ let last = null;
91
+ for (const frame of frames) {
92
+ const data = frame
93
+ .split('\n')
94
+ .filter((l) => l.startsWith('data:'))
95
+ .map((l) => l.slice(5).trim())
96
+ .join('');
97
+ if (!data)
98
+ continue;
99
+ let msg;
100
+ try {
101
+ msg = JSON.parse(data);
102
+ }
103
+ catch {
104
+ this.protocolNotes.push(`Unparseable SSE frame during \`${method}\`: ${data.slice(0, 200)}`);
105
+ continue;
106
+ }
107
+ if (msg.id !== undefined && msg.id !== null)
108
+ last = msg;
109
+ else
110
+ this.serverNotifications.push(msg);
111
+ }
112
+ if (!last)
113
+ throw new TransportClosedError(`SSE stream for \`${method}\` carried no JSON-RPC response`);
114
+ return last;
115
+ }
116
+ notify(method, params) {
117
+ void this.post({ jsonrpc: '2.0', method, ...(params !== undefined ? { params } : {}) }, method, 5000).catch(() => {
118
+ /* Notifications are fire-and-forget; a failure here is not a check result. */
119
+ });
120
+ }
121
+ writeRaw(text) {
122
+ void fetch(this.opts.url, { method: 'POST', headers: this.headers(), body: text }).catch(() => { });
123
+ }
124
+ isAlive() {
125
+ return !this.closed;
126
+ }
127
+ exitInfo() {
128
+ return null;
129
+ }
130
+ async close() {
131
+ this.closed = true;
132
+ }
133
+ }
@@ -0,0 +1,47 @@
1
+ import type { Transport } from './transport.js';
2
+ import type { JsonRpcResponse } from './jsonrpc.js';
3
+ import type { ToolDef } from '../types.js';
4
+ export { StdioTransport } from './stdio.js';
5
+ export { HttpTransport } from './http.js';
6
+ export type { Transport } from './transport.js';
7
+ /** Versions we will negotiate, newest first. */
8
+ export declare const SUPPORTED_PROTOCOL_VERSIONS: string[];
9
+ export interface HandshakeResult {
10
+ raw: JsonRpcResponse;
11
+ protocolVersion: string | null;
12
+ serverInfo: {
13
+ name?: string;
14
+ version?: string;
15
+ } | null;
16
+ capabilities: Record<string, unknown>;
17
+ ms: number;
18
+ }
19
+ /**
20
+ * A thin, unopinionated MCP client. It performs the handshake and exposes the
21
+ * few calls the checks need -- but never normalises or repairs a response,
22
+ * because the checks have to see exactly what the server sent.
23
+ */
24
+ export declare class McpClient {
25
+ readonly transport: Transport;
26
+ private timeoutMs;
27
+ constructor(transport: Transport, timeoutMs?: number);
28
+ get target(): string;
29
+ start(): Promise<void>;
30
+ initialize(protocolVersion?: string): Promise<HandshakeResult>;
31
+ /** The spec requires this notification before any other request. */
32
+ notifyInitialized(): void;
33
+ call(method: string, params?: unknown, timeoutMs?: number): Promise<JsonRpcResponse>;
34
+ callRaw(payload: Record<string, unknown>, id: number | string, method: string, timeoutMs?: number): Promise<JsonRpcResponse>;
35
+ /** Walk `nextCursor` so a paginated server does not under-report. */
36
+ listAll(method: 'tools/list' | 'resources/list' | 'prompts/list', key: string): Promise<{
37
+ items: unknown[];
38
+ pages: number;
39
+ error?: JsonRpcResponse;
40
+ }>;
41
+ listTools(): Promise<{
42
+ tools: ToolDef[];
43
+ pages: number;
44
+ error?: JsonRpcResponse;
45
+ }>;
46
+ close(): Promise<void>;
47
+ }
@@ -0,0 +1,79 @@
1
+ export { StdioTransport } from './stdio.js';
2
+ export { HttpTransport } from './http.js';
3
+ /** Versions we will negotiate, newest first. */
4
+ export const SUPPORTED_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
5
+ /**
6
+ * A thin, unopinionated MCP client. It performs the handshake and exposes the
7
+ * few calls the checks need -- but never normalises or repairs a response,
8
+ * because the checks have to see exactly what the server sent.
9
+ */
10
+ export class McpClient {
11
+ transport;
12
+ timeoutMs;
13
+ constructor(transport, timeoutMs = 10_000) {
14
+ this.transport = transport;
15
+ this.timeoutMs = timeoutMs;
16
+ }
17
+ get target() {
18
+ return this.transport.target;
19
+ }
20
+ async start() {
21
+ await this.transport.start();
22
+ }
23
+ async initialize(protocolVersion = SUPPORTED_PROTOCOL_VERSIONS[0]) {
24
+ const t0 = Date.now();
25
+ const raw = await this.transport.request('initialize', {
26
+ protocolVersion,
27
+ capabilities: { roots: { listChanged: true }, sampling: {}, elicitation: {} },
28
+ clientInfo: { name: 'context-xray', version: '0.1.0' },
29
+ }, this.timeoutMs);
30
+ const ms = Date.now() - t0;
31
+ const result = (raw.result ?? {});
32
+ return {
33
+ raw,
34
+ ms,
35
+ protocolVersion: typeof result['protocolVersion'] === 'string' ? result['protocolVersion'] : null,
36
+ serverInfo: result['serverInfo'] ?? null,
37
+ capabilities: result['capabilities'] ?? {},
38
+ };
39
+ }
40
+ /** The spec requires this notification before any other request. */
41
+ notifyInitialized() {
42
+ this.transport.notify('notifications/initialized');
43
+ }
44
+ call(method, params, timeoutMs) {
45
+ return this.transport.request(method, params, timeoutMs ?? this.timeoutMs);
46
+ }
47
+ callRaw(payload, id, method, timeoutMs) {
48
+ return this.transport.requestRaw(payload, id, method, timeoutMs ?? this.timeoutMs);
49
+ }
50
+ /** Walk `nextCursor` so a paginated server does not under-report. */
51
+ async listAll(method, key) {
52
+ const items = [];
53
+ let cursor;
54
+ let pages = 0;
55
+ for (;;) {
56
+ const res = await this.call(method, cursor ? { cursor } : {});
57
+ if (res.error)
58
+ return { items, pages, error: res };
59
+ pages++;
60
+ const result = (res.result ?? {});
61
+ const batch = result[key];
62
+ if (Array.isArray(batch))
63
+ items.push(...batch);
64
+ const next = result['nextCursor'];
65
+ if (typeof next === 'string' && next && pages < 50)
66
+ cursor = next;
67
+ else
68
+ break;
69
+ }
70
+ return { items, pages };
71
+ }
72
+ async listTools() {
73
+ const { items, pages, error } = await this.listAll('tools/list', 'tools');
74
+ return { tools: items, pages, error };
75
+ }
76
+ close() {
77
+ return this.transport.close();
78
+ }
79
+ }