efaimo 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/CHANGELOG.md +47 -0
- package/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +281 -0
- package/SKILL.md +57 -0
- package/dist/check/check.d.ts +31 -0
- package/dist/check/check.js +77 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +372 -0
- package/dist/clients/introspect.d.ts +8 -0
- package/dist/clients/introspect.js +198 -0
- package/dist/clients/rawprobe.d.ts +69 -0
- package/dist/clients/rawprobe.js +597 -0
- package/dist/clients/stateless.d.ts +7 -0
- package/dist/clients/stateless.js +121 -0
- package/dist/core/engine.d.ts +6 -0
- package/dist/core/engine.js +17 -0
- package/dist/core/grade.d.ts +8 -0
- package/dist/core/grade.js +19 -0
- package/dist/core/types.d.ts +243 -0
- package/dist/core/types.js +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +26 -0
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.js +88 -0
- package/dist/reporters/badge.d.ts +12 -0
- package/dist/reporters/badge.js +37 -0
- package/dist/reporters/json.d.ts +1 -0
- package/dist/reporters/json.js +10 -0
- package/dist/reporters/markdown.d.ts +7 -0
- package/dist/reporters/markdown.js +121 -0
- package/dist/reporters/pretty.d.ts +15 -0
- package/dist/reporters/pretty.js +221 -0
- package/dist/rules/injection.d.ts +20 -0
- package/dist/rules/injection.js +106 -0
- package/dist/rules/mcp/index.d.ts +4 -0
- package/dist/rules/mcp/index.js +643 -0
- package/dist/rules/skill/index.d.ts +2 -0
- package/dist/rules/skill/index.js +279 -0
- package/dist/skills/parse.d.ts +4 -0
- package/dist/skills/parse.js +113 -0
- package/dist/targets/clientConfigs.d.ts +17 -0
- package/dist/targets/clientConfigs.js +110 -0
- package/dist/targets/repoScan.d.ts +2 -0
- package/dist/targets/repoScan.js +0 -0
- package/dist/targets/resolve.d.ts +28 -0
- package/dist/targets/resolve.js +48 -0
- package/dist/testing/anthropicRunner.d.ts +6 -0
- package/dist/testing/anthropicRunner.js +33 -0
- package/dist/testing/harness.d.ts +45 -0
- package/dist/testing/harness.js +96 -0
- package/dist/testing/openaiRunner.d.ts +12 -0
- package/dist/testing/openaiRunner.js +40 -0
- package/dist/util/childEnv.d.ts +1 -0
- package/dist/util/childEnv.js +43 -0
- package/dist/util/dotenv.d.ts +12 -0
- package/dist/util/dotenv.js +53 -0
- package/dist/util/misc.d.ts +15 -0
- package/dist/util/misc.js +121 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/dist/weigh/anthropicExact.d.ts +11 -0
- package/dist/weigh/anthropicExact.js +46 -0
- package/dist/weigh/diff.d.ts +14 -0
- package/dist/weigh/diff.js +19 -0
- package/dist/weigh/serializers.d.ts +21 -0
- package/dist/weigh/serializers.js +44 -0
- package/dist/weigh/tokens.d.ts +12 -0
- package/dist/weigh/tokens.js +23 -0
- package/dist/weigh/weigh.d.ts +6 -0
- package/dist/weigh/weigh.js +104 -0
- package/docs/INTEGRATIONS.md +119 -0
- package/docs/METHODOLOGY.md +83 -0
- package/docs/RULES.md +76 -0
- package/package.json +77 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ProbeResults, ProbeOutcome } from "../core/types.js";
|
|
2
|
+
import type { ResolvedTarget } from "../targets/resolve.js";
|
|
3
|
+
/**
|
|
4
|
+
* Hand-rolled, dependency-light JSON-RPC probes. The SDK client enforces the
|
|
5
|
+
* legacy initialize handshake, so 2026-07-28 readiness experiments (bare
|
|
6
|
+
* stateless requests, server/discover) need raw transport access.
|
|
7
|
+
* Every probe fails soft into a { skipped } marker.
|
|
8
|
+
*/
|
|
9
|
+
export declare const RC_VERSION = "2026-07-28";
|
|
10
|
+
interface RpcReply {
|
|
11
|
+
result?: Record<string, unknown>;
|
|
12
|
+
error?: {
|
|
13
|
+
code: number;
|
|
14
|
+
message: string;
|
|
15
|
+
};
|
|
16
|
+
timeout?: boolean;
|
|
17
|
+
procExit?: number | null;
|
|
18
|
+
}
|
|
19
|
+
export declare function rcMeta(): Record<string, unknown>;
|
|
20
|
+
/**
|
|
21
|
+
* Only an explicit "not initialized" / "session required" error proves the
|
|
22
|
+
* server requires the removed initialize handshake. A plain timeout is NOT proof
|
|
23
|
+
* (the server may just be slow to start) and is reported separately as
|
|
24
|
+
* inconclusive; a crash is its own signal. This keeps E105 from false-flagging a
|
|
25
|
+
* healthy but slow server, the worst verdict a readiness checker can emit.
|
|
26
|
+
*
|
|
27
|
+
* Detection is by message, not by error code: the codes SDKs use here are
|
|
28
|
+
* implementation-defined (TS StreamableHTTP sends -32000 for both "Server not
|
|
29
|
+
* initialized" and "Mcp-Session-Id header is required", -32001 for "Session not
|
|
30
|
+
* found"), -32002 means Resource-not-found in the 2025 specs, and the RC
|
|
31
|
+
* reserves -32022 for UnsupportedProtocolVersion, so no code is diagnostic.
|
|
32
|
+
*/
|
|
33
|
+
export declare function looksLikeInitGate(o: ProbeOutcome): boolean;
|
|
34
|
+
export declare function runProbes(target: Extract<ResolvedTarget, {
|
|
35
|
+
kind: "stdio" | "http";
|
|
36
|
+
}>, opts?: {
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
}): Promise<ProbeResults>;
|
|
39
|
+
export declare class StdioSession {
|
|
40
|
+
private proc;
|
|
41
|
+
private buf;
|
|
42
|
+
private pending;
|
|
43
|
+
private nextId;
|
|
44
|
+
noise: string[];
|
|
45
|
+
stderrTail: string;
|
|
46
|
+
exited: boolean;
|
|
47
|
+
exitCode: number | null;
|
|
48
|
+
constructor(command: string, args: string[], env?: Record<string, string>);
|
|
49
|
+
private onData;
|
|
50
|
+
request(method: string, params: unknown, timeoutMs: number): Promise<RpcReply>;
|
|
51
|
+
notify(method: string, params?: unknown): void;
|
|
52
|
+
private write;
|
|
53
|
+
kill(): void;
|
|
54
|
+
private hardKill;
|
|
55
|
+
}
|
|
56
|
+
export interface HttpReply {
|
|
57
|
+
status: number;
|
|
58
|
+
headers: Headers;
|
|
59
|
+
body?: {
|
|
60
|
+
result?: Record<string, unknown>;
|
|
61
|
+
error?: {
|
|
62
|
+
code: number;
|
|
63
|
+
message: string;
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
authHeader?: string;
|
|
67
|
+
}
|
|
68
|
+
export declare function postMessage(url: string, extraHeaders: Record<string, string> | undefined, msg: Record<string, unknown>, sessionId?: string, protocolVersion?: string): Promise<HttpReply>;
|
|
69
|
+
export {};
|
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
import crossSpawn from "cross-spawn";
|
|
2
|
+
import { minimalChildEnv } from "../util/childEnv.js";
|
|
3
|
+
import { truncate } from "../util/misc.js";
|
|
4
|
+
import { VERSION } from "../version.js";
|
|
5
|
+
/**
|
|
6
|
+
* Hand-rolled, dependency-light JSON-RPC probes. The SDK client enforces the
|
|
7
|
+
* legacy initialize handshake, so 2026-07-28 readiness experiments (bare
|
|
8
|
+
* stateless requests, server/discover) need raw transport access.
|
|
9
|
+
* Every probe fails soft into a { skipped } marker.
|
|
10
|
+
*/
|
|
11
|
+
export const RC_VERSION = "2026-07-28";
|
|
12
|
+
const LEGACY_VERSION = "2025-06-18";
|
|
13
|
+
export function rcMeta() {
|
|
14
|
+
return {
|
|
15
|
+
_meta: {
|
|
16
|
+
"io.modelcontextprotocol/protocolVersion": RC_VERSION,
|
|
17
|
+
"io.modelcontextprotocol/clientCapabilities": {},
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function toOutcome(reply, timeoutMessage) {
|
|
22
|
+
if (reply.error)
|
|
23
|
+
return { ok: false, kind: "error", errorCode: reply.error.code, errorMessage: reply.error.message };
|
|
24
|
+
if (reply.procExit !== undefined) {
|
|
25
|
+
return { ok: false, kind: "exit", errorMessage: `server process exited (code ${reply.procExit ?? "unknown"}) on bare request` };
|
|
26
|
+
}
|
|
27
|
+
if (reply.timeout)
|
|
28
|
+
return { ok: false, kind: "timeout", errorMessage: timeoutMessage };
|
|
29
|
+
// Neither error, exit, nor timeout: a JSON-RPC success (result may be null).
|
|
30
|
+
return { ok: true, kind: "ok" };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Only an explicit "not initialized" / "session required" error proves the
|
|
34
|
+
* server requires the removed initialize handshake. A plain timeout is NOT proof
|
|
35
|
+
* (the server may just be slow to start) and is reported separately as
|
|
36
|
+
* inconclusive; a crash is its own signal. This keeps E105 from false-flagging a
|
|
37
|
+
* healthy but slow server, the worst verdict a readiness checker can emit.
|
|
38
|
+
*
|
|
39
|
+
* Detection is by message, not by error code: the codes SDKs use here are
|
|
40
|
+
* implementation-defined (TS StreamableHTTP sends -32000 for both "Server not
|
|
41
|
+
* initialized" and "Mcp-Session-Id header is required", -32001 for "Session not
|
|
42
|
+
* found"), -32002 means Resource-not-found in the 2025 specs, and the RC
|
|
43
|
+
* reserves -32022 for UnsupportedProtocolVersion, so no code is diagnostic.
|
|
44
|
+
*/
|
|
45
|
+
export function looksLikeInitGate(o) {
|
|
46
|
+
if (o.kind !== "error")
|
|
47
|
+
return false;
|
|
48
|
+
return /not\s*initiali|before\s+initiali|require[sd]?\s+initiali|no\s+valid\s+session|session[-\s]?id|session\s+(?:not\s+found|required)/i.test(o.errorMessage ?? "");
|
|
49
|
+
}
|
|
50
|
+
function arraysEqual(a, b) {
|
|
51
|
+
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
52
|
+
}
|
|
53
|
+
export async function runProbes(target, opts = {}) {
|
|
54
|
+
const budget = opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : 45000;
|
|
55
|
+
try {
|
|
56
|
+
return target.kind === "stdio" ? await probeStdio(target, budget) : await probeHttp(target);
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
return {
|
|
60
|
+
bareToolsList: { skipped: `probe crashed: ${e.message}` },
|
|
61
|
+
serverDiscover: { skipped: "probe crashed" },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/* ------------------------------ stdio ------------------------------ */
|
|
66
|
+
export class StdioSession {
|
|
67
|
+
proc;
|
|
68
|
+
buf = "";
|
|
69
|
+
pending = new Map();
|
|
70
|
+
nextId = 1;
|
|
71
|
+
noise = [];
|
|
72
|
+
stderrTail = "";
|
|
73
|
+
exited = false;
|
|
74
|
+
exitCode = null;
|
|
75
|
+
constructor(command, args, env) {
|
|
76
|
+
this.proc = crossSpawn(command, args, {
|
|
77
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
78
|
+
env: { ...minimalChildEnv(), ...(env ?? {}) },
|
|
79
|
+
windowsHide: true,
|
|
80
|
+
});
|
|
81
|
+
this.proc.stdout?.on("data", (c) => this.onData(c.toString("utf8")));
|
|
82
|
+
this.proc.stderr?.on("data", (c) => {
|
|
83
|
+
if (this.stderrTail.length < 2048)
|
|
84
|
+
this.stderrTail += c.toString("utf8");
|
|
85
|
+
});
|
|
86
|
+
const finish = (code) => {
|
|
87
|
+
this.exited = true;
|
|
88
|
+
this.exitCode = code;
|
|
89
|
+
for (const resolve of this.pending.values())
|
|
90
|
+
resolve({ procExit: code });
|
|
91
|
+
this.pending.clear();
|
|
92
|
+
};
|
|
93
|
+
this.proc.on("exit", finish);
|
|
94
|
+
this.proc.on("error", () => finish(null));
|
|
95
|
+
}
|
|
96
|
+
onData(s) {
|
|
97
|
+
this.buf += s;
|
|
98
|
+
// Bound memory: a hostile server can stream endlessly with no newline. If the
|
|
99
|
+
// pending line grows past the cap, drop it (it cannot be a real JSON-RPC line).
|
|
100
|
+
if (this.buf.length > 4 * 1024 * 1024) {
|
|
101
|
+
if (this.noise.length < 5)
|
|
102
|
+
this.noise.push("[oversized line dropped: >4MB without a newline]");
|
|
103
|
+
this.buf = "";
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
let idx;
|
|
107
|
+
while ((idx = this.buf.indexOf("\n")) !== -1) {
|
|
108
|
+
const line = this.buf.slice(0, idx).trim();
|
|
109
|
+
this.buf = this.buf.slice(idx + 1);
|
|
110
|
+
if (!line)
|
|
111
|
+
continue;
|
|
112
|
+
let msg;
|
|
113
|
+
try {
|
|
114
|
+
msg = JSON.parse(line);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
if (this.noise.length < 5)
|
|
118
|
+
this.noise.push(truncate(line, 160));
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const m = msg;
|
|
122
|
+
if (typeof m.id === "number" && this.pending.has(m.id)) {
|
|
123
|
+
const resolve = this.pending.get(m.id);
|
|
124
|
+
this.pending.delete(m.id);
|
|
125
|
+
resolve({ result: m.result, error: m.error });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
request(method, params, timeoutMs) {
|
|
130
|
+
if (this.exited)
|
|
131
|
+
return Promise.resolve({ procExit: this.exitCode });
|
|
132
|
+
const id = this.nextId++;
|
|
133
|
+
return new Promise((resolve) => {
|
|
134
|
+
const timer = setTimeout(() => {
|
|
135
|
+
this.pending.delete(id);
|
|
136
|
+
resolve({ timeout: true });
|
|
137
|
+
}, timeoutMs);
|
|
138
|
+
timer.unref();
|
|
139
|
+
this.pending.set(id, (r) => {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
resolve(r);
|
|
142
|
+
});
|
|
143
|
+
this.write({ jsonrpc: "2.0", id, method, ...(params !== undefined ? { params } : {}) });
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
notify(method, params) {
|
|
147
|
+
this.write({ jsonrpc: "2.0", method, ...(params !== undefined ? { params } : {}) });
|
|
148
|
+
}
|
|
149
|
+
write(obj) {
|
|
150
|
+
try {
|
|
151
|
+
this.proc.stdin?.write(JSON.stringify(obj) + "\n");
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
/* ignore */
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
kill() {
|
|
158
|
+
try {
|
|
159
|
+
this.proc.stdin?.end();
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
/* ignore */
|
|
163
|
+
}
|
|
164
|
+
const t = setTimeout(() => this.hardKill(), 200);
|
|
165
|
+
t.unref();
|
|
166
|
+
}
|
|
167
|
+
hardKill() {
|
|
168
|
+
if (this.exited)
|
|
169
|
+
return;
|
|
170
|
+
const pid = this.proc.pid;
|
|
171
|
+
// On Windows, npx/.cmd servers run under a cmd.exe wrapper; proc.kill()
|
|
172
|
+
// ends only the wrapper and orphans the real server. taskkill /T kills the
|
|
173
|
+
// whole tree. cross-spawn resolves taskkill without a shell.
|
|
174
|
+
if (pid !== undefined && process.platform === "win32") {
|
|
175
|
+
try {
|
|
176
|
+
crossSpawn.sync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore" });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
/* fall through to kill() */
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
this.proc.kill();
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
/* ignore */
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function hasCacheFields(result) {
|
|
192
|
+
return (Object.prototype.hasOwnProperty.call(result, "ttlMs") &&
|
|
193
|
+
Object.prototype.hasOwnProperty.call(result, "cacheScope"));
|
|
194
|
+
}
|
|
195
|
+
function toolOrder(result) {
|
|
196
|
+
const tools = result?.tools ?? [];
|
|
197
|
+
return tools.map((x) => x?.name).filter((n) => typeof n === "string");
|
|
198
|
+
}
|
|
199
|
+
async function probeStdio(target, budget) {
|
|
200
|
+
const results = {};
|
|
201
|
+
// A responsive server answers the bare request in well under a second, so a
|
|
202
|
+
// generous cap only affects genuinely slow/hung servers (introspect already
|
|
203
|
+
// proved the server starts and warmed the npx cache). A small --timeout is
|
|
204
|
+
// honored rather than floored.
|
|
205
|
+
const bareT = Math.min(Math.max(budget, 1), 15000);
|
|
206
|
+
const discoverT = Math.min(Math.max(budget, 1), 10000);
|
|
207
|
+
const sessions = [];
|
|
208
|
+
// A bare stateless tools/list. BOTH legacy (the reference SDK has no init gate)
|
|
209
|
+
// and RC servers answer it, so RC vs legacy is decided by the RESULT shape
|
|
210
|
+
// (resultType, cache fields, server/discover), never by whether it answered.
|
|
211
|
+
const s1 = new StdioSession(target.command, target.args, target.env);
|
|
212
|
+
sessions.push(s1);
|
|
213
|
+
const bare = await s1.request("tools/list", rcMeta(), bareT);
|
|
214
|
+
const bareOutcome = toOutcome(bare, `no response within ${Math.round(bareT / 1000)}s (the server may require the removed initialize handshake)`);
|
|
215
|
+
results.bareToolsList = bareOutcome;
|
|
216
|
+
let order1;
|
|
217
|
+
if (bareOutcome.ok && bare.result) {
|
|
218
|
+
results.resultTypePresent = Object.prototype.hasOwnProperty.call(bare.result, "resultType");
|
|
219
|
+
results.cacheFieldsPresent = hasCacheFields(bare.result);
|
|
220
|
+
order1 = toolOrder(bare.result);
|
|
221
|
+
}
|
|
222
|
+
// Probe server/discover only if the server is responsive (answered or errored,
|
|
223
|
+
// not timed out), to avoid a second long wait on an unresponsive server.
|
|
224
|
+
if (!s1.exited && bareOutcome.kind !== "timeout") {
|
|
225
|
+
const d = await s1.request("server/discover", rcMeta(), discoverT);
|
|
226
|
+
if (d.result)
|
|
227
|
+
results.serverDiscover = { supported: true };
|
|
228
|
+
else if (d.error && d.error.code === -32601) {
|
|
229
|
+
results.serverDiscover = { supported: false, errorMessage: `${d.error.code} ${d.error.message}` };
|
|
230
|
+
}
|
|
231
|
+
else if (d.error) {
|
|
232
|
+
results.serverDiscover = { skipped: `inconclusive (${d.error.code} ${d.error.message})` };
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
results.serverDiscover = { skipped: "no answer to server/discover" };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
results.serverDiscover = { skipped: "server did not answer the bare request" };
|
|
240
|
+
}
|
|
241
|
+
s1.kill();
|
|
242
|
+
// Determinism (E112): a second fresh bare run, compared by tool order.
|
|
243
|
+
if (order1 && order1.length > 1) {
|
|
244
|
+
const s2 = new StdioSession(target.command, target.args, target.env);
|
|
245
|
+
sessions.push(s2);
|
|
246
|
+
const bare2 = await s2.request("tools/list", rcMeta(), bareT);
|
|
247
|
+
s2.kill();
|
|
248
|
+
if (bare2.result) {
|
|
249
|
+
const order2 = toolOrder(bare2.result);
|
|
250
|
+
if (order2.length)
|
|
251
|
+
results.toolsOrderDeterministic = arraysEqual(order1, order2);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const noise = [...new Set(sessions.flatMap((s) => s.noise))];
|
|
255
|
+
if (noise.length)
|
|
256
|
+
results.stdoutNoise = truncate(noise.join(" | "), 500);
|
|
257
|
+
return results;
|
|
258
|
+
}
|
|
259
|
+
export async function postMessage(url, extraHeaders, msg, sessionId, protocolVersion = LEGACY_VERSION) {
|
|
260
|
+
const method = typeof msg.method === "string" ? msg.method : undefined;
|
|
261
|
+
const res = await fetch(url, {
|
|
262
|
+
method: "POST",
|
|
263
|
+
headers: {
|
|
264
|
+
"content-type": "application/json",
|
|
265
|
+
accept: "application/json, text/event-stream",
|
|
266
|
+
"mcp-protocol-version": protocolVersion,
|
|
267
|
+
// Mcp-Method is well-defined (SEP-2243); Mcp-Name is required too but its
|
|
268
|
+
// value is not the method name, so it is omitted rather than sent wrong.
|
|
269
|
+
...(method ? { "Mcp-Method": method } : {}),
|
|
270
|
+
...(sessionId ? { "mcp-session-id": sessionId } : {}),
|
|
271
|
+
...(extraHeaders ?? {}),
|
|
272
|
+
},
|
|
273
|
+
body: JSON.stringify(msg),
|
|
274
|
+
// Never follow redirects: a hostile target could redirect an authenticated
|
|
275
|
+
// request to an internal host and replay the user's --header credentials.
|
|
276
|
+
redirect: "error",
|
|
277
|
+
signal: AbortSignal.timeout(10000),
|
|
278
|
+
});
|
|
279
|
+
if (res.status === 401 || res.status === 403) {
|
|
280
|
+
return { status: res.status, headers: res.headers, authHeader: res.headers.get("www-authenticate") ?? undefined };
|
|
281
|
+
}
|
|
282
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
283
|
+
if (ct.includes("application/json")) {
|
|
284
|
+
const body = (await res.json().catch(() => undefined));
|
|
285
|
+
return { status: res.status, headers: res.headers, body };
|
|
286
|
+
}
|
|
287
|
+
if (ct.includes("text/event-stream") && res.body) {
|
|
288
|
+
// readSseForId cancels the reader (and thus the stream) in its finally.
|
|
289
|
+
const body = await readSseForId(res.body, msg.id, 8000);
|
|
290
|
+
return { status: res.status, headers: res.headers, body };
|
|
291
|
+
}
|
|
292
|
+
return { status: res.status, headers: res.headers };
|
|
293
|
+
}
|
|
294
|
+
async function readSseForId(stream, id, timeoutMs) {
|
|
295
|
+
const reader = stream.getReader();
|
|
296
|
+
const decoder = new TextDecoder();
|
|
297
|
+
let buf = "";
|
|
298
|
+
let timer;
|
|
299
|
+
const timeout = new Promise((resolve) => {
|
|
300
|
+
timer = setTimeout(() => resolve("TIMEOUT"), timeoutMs);
|
|
301
|
+
timer.unref?.();
|
|
302
|
+
});
|
|
303
|
+
try {
|
|
304
|
+
for (;;) {
|
|
305
|
+
const r = await Promise.race([reader.read(), timeout]);
|
|
306
|
+
if (r === "TIMEOUT" || r.done)
|
|
307
|
+
break;
|
|
308
|
+
buf += decoder.decode(r.value, { stream: true });
|
|
309
|
+
let sep;
|
|
310
|
+
while ((sep = buf.indexOf("\n\n")) !== -1) {
|
|
311
|
+
const event = buf.slice(0, sep);
|
|
312
|
+
buf = buf.slice(sep + 2);
|
|
313
|
+
const data = event
|
|
314
|
+
.split("\n")
|
|
315
|
+
.filter((l) => l.startsWith("data:"))
|
|
316
|
+
.map((l) => l.slice(5).trim())
|
|
317
|
+
.join("\n");
|
|
318
|
+
if (!data)
|
|
319
|
+
continue;
|
|
320
|
+
try {
|
|
321
|
+
const msg = JSON.parse(data);
|
|
322
|
+
if (id === undefined || msg.id === id)
|
|
323
|
+
return { result: msg.result, error: msg.error };
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
/* ignore non-JSON data */
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
/* stream error */
|
|
333
|
+
}
|
|
334
|
+
finally {
|
|
335
|
+
clearTimeout(timer);
|
|
336
|
+
// cancel() releases the lock and tears down the stream cleanly, even with a
|
|
337
|
+
// read() still pending (releaseLock() would throw in that case).
|
|
338
|
+
try {
|
|
339
|
+
await reader.cancel();
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
/* ignore */
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
function outcomeFromHttp(reply) {
|
|
348
|
+
if (reply.body?.error) {
|
|
349
|
+
// A stateful HTTP server rejecting a bare request (session/not-initialized)
|
|
350
|
+
// shows up here; kind:"error" lets looksLikeInitGate classify it.
|
|
351
|
+
return { ok: false, kind: "error", errorCode: reply.body.error.code, errorMessage: reply.body.error.message };
|
|
352
|
+
}
|
|
353
|
+
if (reply.body && "result" in reply.body)
|
|
354
|
+
return { ok: true, kind: "ok" };
|
|
355
|
+
return { ok: false, kind: "error", errorMessage: `HTTP ${reply.status} with no JSON-RPC result` };
|
|
356
|
+
}
|
|
357
|
+
/** Reject non-https and private/loopback/link-local hosts to bound SSRF via
|
|
358
|
+
* attacker-controlled OAuth metadata URLs (see the target's WWW-Authenticate). */
|
|
359
|
+
function isPrivateHost(hostname) {
|
|
360
|
+
const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
361
|
+
if (h === "localhost" || h.endsWith(".localhost") || h.endsWith(".local") || h.endsWith(".internal"))
|
|
362
|
+
return true;
|
|
363
|
+
if (h === "::1" || h === "0.0.0.0")
|
|
364
|
+
return true;
|
|
365
|
+
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.\d{1,3}$/);
|
|
366
|
+
if (m) {
|
|
367
|
+
const a = Number(m[1]);
|
|
368
|
+
const b = Number(m[2]);
|
|
369
|
+
if (a === 127 || a === 10 || a === 0)
|
|
370
|
+
return true;
|
|
371
|
+
if (a === 169 && b === 254)
|
|
372
|
+
return true; // link-local incl. 169.254.169.254
|
|
373
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
374
|
+
return true;
|
|
375
|
+
if (a === 192 && b === 168)
|
|
376
|
+
return true;
|
|
377
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
378
|
+
return true; // CGNAT
|
|
379
|
+
}
|
|
380
|
+
// IPv4-mapped IPv6, e.g. ::ffff:169.254.169.254 or ::ffff:a9fe:a9fe
|
|
381
|
+
const mapped = h.match(/^::ffff:(.+)$/);
|
|
382
|
+
if (mapped) {
|
|
383
|
+
const tail = mapped[1];
|
|
384
|
+
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(tail))
|
|
385
|
+
return isPrivateHost(tail);
|
|
386
|
+
const hx = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
387
|
+
if (hx) {
|
|
388
|
+
const n = ((parseInt(hx[1], 16) << 16) | parseInt(hx[2], 16)) >>> 0;
|
|
389
|
+
return isPrivateHost(`${(n >>> 24) & 255}.${(n >>> 16) & 255}.${(n >>> 8) & 255}.${n & 255}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (/^f[cd][0-9a-f]{2}:/.test(h) || /^fe80:/.test(h))
|
|
393
|
+
return true;
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
async function safeMetadataFetch(raw) {
|
|
397
|
+
let u;
|
|
398
|
+
try {
|
|
399
|
+
u = new URL(raw);
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
throw new Error("invalid url");
|
|
403
|
+
}
|
|
404
|
+
if (u.protocol !== "https:")
|
|
405
|
+
throw new Error("non-https metadata url refused");
|
|
406
|
+
if (isPrivateHost(u.hostname))
|
|
407
|
+
throw new Error("private-host metadata url refused");
|
|
408
|
+
// Resolve the name and reject if it points at a private address (a DNS name
|
|
409
|
+
// with an A record for 169.254.169.254 defeats a hostname-only check).
|
|
410
|
+
try {
|
|
411
|
+
const { lookup } = await import("node:dns/promises");
|
|
412
|
+
const addrs = await lookup(u.hostname, { all: true });
|
|
413
|
+
if (addrs.some((r) => isPrivateHost(r.address)))
|
|
414
|
+
throw new Error("metadata host resolves to a private address");
|
|
415
|
+
}
|
|
416
|
+
catch (e) {
|
|
417
|
+
if (e.message.includes("private address"))
|
|
418
|
+
throw e;
|
|
419
|
+
// a plain DNS failure is left for fetch to surface
|
|
420
|
+
}
|
|
421
|
+
return fetch(u, { signal: AbortSignal.timeout(6000), redirect: "error", headers: { accept: "application/json" } });
|
|
422
|
+
}
|
|
423
|
+
async function analyzeAuth(wwwAuthenticate) {
|
|
424
|
+
const out = { required: true, wwwAuthenticate };
|
|
425
|
+
if (!wwwAuthenticate)
|
|
426
|
+
return out;
|
|
427
|
+
const m = wwwAuthenticate.match(/resource_metadata="?([^",\s]+)"?/i);
|
|
428
|
+
if (!m)
|
|
429
|
+
return out;
|
|
430
|
+
out.resourceMetadataUrl = m[1];
|
|
431
|
+
try {
|
|
432
|
+
const res = await safeMetadataFetch(m[1]);
|
|
433
|
+
if (!res.ok)
|
|
434
|
+
return out;
|
|
435
|
+
const meta = (await res.json());
|
|
436
|
+
const as = meta.authorization_servers?.[0];
|
|
437
|
+
if (!as)
|
|
438
|
+
return out;
|
|
439
|
+
out.authorizationServer = as;
|
|
440
|
+
for (const candidate of [
|
|
441
|
+
`${as.replace(/\/$/, "")}/.well-known/oauth-authorization-server`,
|
|
442
|
+
`${new URL(as).origin}/.well-known/oauth-authorization-server`,
|
|
443
|
+
]) {
|
|
444
|
+
try {
|
|
445
|
+
const asRes = await safeMetadataFetch(candidate);
|
|
446
|
+
if (!asRes.ok)
|
|
447
|
+
continue;
|
|
448
|
+
const asMeta = (await asRes.json());
|
|
449
|
+
out.dcrRegistrationEndpoint = typeof asMeta.registration_endpoint === "string";
|
|
450
|
+
const cimd = asMeta.client_id_metadata_document_supported;
|
|
451
|
+
out.cimdSupported = typeof cimd === "boolean" ? cimd : undefined;
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
/* try next candidate */
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
/* leave partial info */
|
|
461
|
+
}
|
|
462
|
+
return out;
|
|
463
|
+
}
|
|
464
|
+
async function probeHttp(target) {
|
|
465
|
+
const results = {};
|
|
466
|
+
const url = target.url;
|
|
467
|
+
const headers = target.headers;
|
|
468
|
+
const bare = await postMessage(url, headers, { jsonrpc: "2.0", id: 1, method: "tools/list", params: rcMeta() }, undefined, RC_VERSION).catch((e) => ({ status: 0, headers: new Headers(), authHeader: undefined, body: undefined, error: e }));
|
|
469
|
+
if (bare.error) {
|
|
470
|
+
const err = bare.error;
|
|
471
|
+
const isTimeout = /timeout|timed out|aborted/i.test(`${err.name} ${err.message}`);
|
|
472
|
+
results.bareToolsList = isTimeout
|
|
473
|
+
? { ok: false, kind: "timeout", errorMessage: `request timed out: ${err.message}` }
|
|
474
|
+
: { skipped: `request failed: ${err.message}` };
|
|
475
|
+
}
|
|
476
|
+
else if (bare.authHeader !== undefined || bare.status === 401 || bare.status === 403) {
|
|
477
|
+
results.httpAuth = await analyzeAuth(bare.authHeader);
|
|
478
|
+
results.bareToolsList = { skipped: "authentication required" };
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
results.bareToolsList = outcomeFromHttp(bare);
|
|
482
|
+
// Measure RC fields on the bare stateless result, the same way stdio does.
|
|
483
|
+
if (bare.body && "result" in bare.body && bare.body.result) {
|
|
484
|
+
results.resultTypePresent = Object.prototype.hasOwnProperty.call(bare.body.result, "resultType");
|
|
485
|
+
results.cacheFieldsPresent = hasCacheFields(bare.body.result);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// Probe server/discover on the bare stateless path too, exactly like stdio: a
|
|
489
|
+
// 2026-07-28 server has no initialize, so waiting for the legacy branch would
|
|
490
|
+
// leave E106 (a MUST rule) unmeasured on precisely the servers it targets.
|
|
491
|
+
if (results.bareToolsList && "ok" in results.bareToolsList && results.bareToolsList.ok) {
|
|
492
|
+
try {
|
|
493
|
+
const d = await postMessage(url, headers, { jsonrpc: "2.0", id: 7, method: "server/discover", params: rcMeta() }, undefined, RC_VERSION);
|
|
494
|
+
if (d.body?.result)
|
|
495
|
+
results.serverDiscover = { supported: true };
|
|
496
|
+
else if (d.body?.error && d.body.error.code === -32601) {
|
|
497
|
+
results.serverDiscover = { supported: false, errorMessage: `${d.body.error.code} ${d.body.error.message}` };
|
|
498
|
+
}
|
|
499
|
+
else if (d.body?.error) {
|
|
500
|
+
results.serverDiscover = { skipped: `inconclusive (${d.body.error.code} ${d.body.error.message})` };
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
/* leave for the legacy branch to try */
|
|
505
|
+
}
|
|
506
|
+
// Determinism (E112) on the bare path: a second bare request, like stdio.
|
|
507
|
+
const bareTools = toolOrder(bare.body && "result" in bare.body ? bare.body.result : undefined);
|
|
508
|
+
if (bareTools.length > 1) {
|
|
509
|
+
try {
|
|
510
|
+
const again = await postMessage(url, headers, { jsonrpc: "2.0", id: 8, method: "tools/list", params: rcMeta() }, undefined, RC_VERSION);
|
|
511
|
+
const order2 = toolOrder(again.body?.result);
|
|
512
|
+
if (order2.length)
|
|
513
|
+
results.toolsOrderDeterministic = arraysEqual(bareTools, order2);
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
/* inconclusive */
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (!results.httpAuth) {
|
|
521
|
+
try {
|
|
522
|
+
const init = await postMessage(url, headers, {
|
|
523
|
+
jsonrpc: "2.0",
|
|
524
|
+
id: 2,
|
|
525
|
+
method: "initialize",
|
|
526
|
+
params: { protocolVersion: LEGACY_VERSION, capabilities: {}, clientInfo: { name: "efaimo-probe", version: VERSION } },
|
|
527
|
+
});
|
|
528
|
+
if (init.authHeader !== undefined) {
|
|
529
|
+
results.httpAuth = await analyzeAuth(init.authHeader);
|
|
530
|
+
}
|
|
531
|
+
else if (init.body?.result) {
|
|
532
|
+
const sessionId = init.headers.get("mcp-session-id") ?? undefined;
|
|
533
|
+
await postMessage(url, headers, { jsonrpc: "2.0", method: "notifications/initialized" }, sessionId).catch(() => undefined);
|
|
534
|
+
const t1 = await postMessage(url, headers, { jsonrpc: "2.0", id: 3, method: "tools/list", params: {} }, sessionId);
|
|
535
|
+
let order1;
|
|
536
|
+
if (t1.body?.result) {
|
|
537
|
+
// Never overwrite what the bare stateless path already measured: a
|
|
538
|
+
// dual-stack server legitimately answers legacy-version requests with
|
|
539
|
+
// legacy-shaped results, and clobbering the RC measurements here
|
|
540
|
+
// would fabricate readiness findings on the best-migrated servers.
|
|
541
|
+
results.resultTypePresent ??= Object.prototype.hasOwnProperty.call(t1.body.result, "resultType");
|
|
542
|
+
results.cacheFieldsPresent ??= hasCacheFields(t1.body.result);
|
|
543
|
+
const tools = t1.body.result.tools ?? [];
|
|
544
|
+
order1 = tools.map((x) => x?.name).filter((n) => typeof n === "string");
|
|
545
|
+
}
|
|
546
|
+
if (results.serverDiscover === undefined) {
|
|
547
|
+
const d = await postMessage(url, headers, { jsonrpc: "2.0", id: 4, method: "server/discover", params: {} }, sessionId);
|
|
548
|
+
if (d.body?.result)
|
|
549
|
+
results.serverDiscover = { supported: true };
|
|
550
|
+
else if (d.body?.error && d.body.error.code === -32601) {
|
|
551
|
+
results.serverDiscover = { supported: false, errorMessage: `${d.body.error.code} ${d.body.error.message}` };
|
|
552
|
+
}
|
|
553
|
+
else if (d.body?.error) {
|
|
554
|
+
results.serverDiscover = { skipped: `inconclusive (${d.body.error.code} ${d.body.error.message})` };
|
|
555
|
+
}
|
|
556
|
+
else
|
|
557
|
+
results.serverDiscover = { skipped: "no answer to server/discover" };
|
|
558
|
+
}
|
|
559
|
+
if (order1 && order1.length > 1 && results.toolsOrderDeterministic === undefined) {
|
|
560
|
+
const init2 = await postMessage(url, headers, {
|
|
561
|
+
jsonrpc: "2.0",
|
|
562
|
+
id: 5,
|
|
563
|
+
method: "initialize",
|
|
564
|
+
params: { protocolVersion: LEGACY_VERSION, capabilities: {}, clientInfo: { name: "efaimo-probe", version: VERSION } },
|
|
565
|
+
});
|
|
566
|
+
if (init2.body?.result) {
|
|
567
|
+
const session2 = init2.headers.get("mcp-session-id") ?? undefined;
|
|
568
|
+
await postMessage(url, headers, { jsonrpc: "2.0", method: "notifications/initialized" }, session2).catch(() => undefined);
|
|
569
|
+
const t2 = await postMessage(url, headers, { jsonrpc: "2.0", id: 6, method: "tools/list", params: {} }, session2);
|
|
570
|
+
if (t2.body?.result) {
|
|
571
|
+
const tools2 = t2.body.result.tools ?? [];
|
|
572
|
+
const order2 = tools2.map((x) => x?.name).filter((n) => typeof n === "string");
|
|
573
|
+
results.toolsOrderDeterministic = arraysEqual(order1, order2);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
catch (e) {
|
|
580
|
+
results.serverDiscover = results.serverDiscover ?? { skipped: `legacy session failed: ${e.message}` };
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
try {
|
|
584
|
+
const origin = new URL(url).origin;
|
|
585
|
+
const cardUrl = `${origin}/.well-known/mcp`;
|
|
586
|
+
// redirect:"error" so a hostile target cannot bounce this to an internal host.
|
|
587
|
+
const sc = await fetch(cardUrl, { signal: AbortSignal.timeout(5000), redirect: "error", headers: { accept: "application/json" } });
|
|
588
|
+
results.serverCard = {
|
|
589
|
+
found: sc.ok && (sc.headers.get("content-type") ?? "").includes("json"),
|
|
590
|
+
url: cardUrl,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
results.serverCard = { skipped: "well-known lookup failed" };
|
|
595
|
+
}
|
|
596
|
+
return results;
|
|
597
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ServerIntrospection } from "../core/types.js";
|
|
2
|
+
import type { ResolvedTarget } from "../targets/resolve.js";
|
|
3
|
+
export declare function introspectStateless(target: Extract<ResolvedTarget, {
|
|
4
|
+
kind: "stdio" | "http";
|
|
5
|
+
}>, opts?: {
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
}): Promise<ServerIntrospection | undefined>;
|