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
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
|
+
[](https://github.com/Beeeeen/context-xray/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/context-xray)
|
|
7
|
+
[](./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
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
|
+
}
|