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,643 @@
|
|
|
1
|
+
import { scanTextForInjection } from "../injection.js";
|
|
2
|
+
import { looksLikeInitGate } from "../../clients/rawprobe.js";
|
|
3
|
+
import { formatTokens, truncate } from "../../util/misc.js";
|
|
4
|
+
const RC = "2026-07-28";
|
|
5
|
+
function isOutcome(x) {
|
|
6
|
+
return !!x && typeof x === "object" && "ok" in x;
|
|
7
|
+
}
|
|
8
|
+
function capList(items, cap) {
|
|
9
|
+
return { shown: items.slice(0, cap), more: Math.max(0, items.length - cap) };
|
|
10
|
+
}
|
|
11
|
+
function repoMatchesFinding(ctx, category, make) {
|
|
12
|
+
const matches = ctx.repo?.matches.filter((m) => m.category === category) ?? [];
|
|
13
|
+
if (!matches.length)
|
|
14
|
+
return [];
|
|
15
|
+
const { shown, more } = capList(matches, 3);
|
|
16
|
+
const detail = shown.map((m) => `${m.file}:${m.line} ${m.excerpt}`).join("\n") +
|
|
17
|
+
(more ? `\n(+${more} more)` : "") +
|
|
18
|
+
"\n(pattern match; verify manually)";
|
|
19
|
+
return [make(detail, matches.length)];
|
|
20
|
+
}
|
|
21
|
+
const matchStr = (c) => `${c} match${c === 1 ? "" : "es"}`;
|
|
22
|
+
const REPO_MATCH_RULES = [
|
|
23
|
+
{
|
|
24
|
+
id: "E102",
|
|
25
|
+
category: "sampling",
|
|
26
|
+
severity: "warn",
|
|
27
|
+
title: "uses deprecated Sampling",
|
|
28
|
+
message: (c) => `source references sampling/createMessage (${matchStr(c)}); Sampling is deprecated in ${RC} (SEP-2577)`,
|
|
29
|
+
fixHint: "integrate directly with an LLM provider API instead of MCP Sampling",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "E103",
|
|
33
|
+
category: "roots",
|
|
34
|
+
severity: "warn",
|
|
35
|
+
title: "uses deprecated Roots",
|
|
36
|
+
message: (c) => `source references roots/list (${matchStr(c)}); Roots is deprecated in ${RC} (SEP-2577)`,
|
|
37
|
+
fixHint: "pass directories or files via tool parameters, resource URIs, or server configuration",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "E108",
|
|
41
|
+
category: "sse-resume",
|
|
42
|
+
severity: "info",
|
|
43
|
+
title: "relies on removed SSE resumability",
|
|
44
|
+
message: (c) => `source references Last-Event-ID/resumability (${matchStr(c)}); stream resumability is removed in ${RC}`,
|
|
45
|
+
fixHint: "persist cross-call state behind server-minted handles passed as tool arguments",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "E110",
|
|
49
|
+
category: "elicitation",
|
|
50
|
+
severity: "warn",
|
|
51
|
+
title: "uses legacy elicitation",
|
|
52
|
+
message: (c) => `source references elicitation/create (${matchStr(c)}); replaced in ${RC} by MRTR results (resultType "input_required" with inputRequests)`,
|
|
53
|
+
fixHint: "return input_required results and correlate retries via requestState (SEP-2322)",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "E111",
|
|
57
|
+
category: "session-state",
|
|
58
|
+
severity: "info",
|
|
59
|
+
title: "possible in-process session state",
|
|
60
|
+
message: (c) => `source shows session-state patterns (${matchStr(c)}); ${RC} statelessness expects server-minted handles passed via tool arguments`,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "E114",
|
|
64
|
+
category: "ping",
|
|
65
|
+
severity: "info",
|
|
66
|
+
title: "uses removed ping",
|
|
67
|
+
message: (c) => `source references the ping utility (${matchStr(c)}); ping is removed in ${RC}`,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: "E115",
|
|
71
|
+
category: "subscribe",
|
|
72
|
+
severity: "info",
|
|
73
|
+
title: "uses replaced resource subscriptions",
|
|
74
|
+
message: (c) => `source references resources/subscribe (${matchStr(c)}); replaced by subscriptions/listen in ${RC}`,
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
const repoMatchRules = REPO_MATCH_RULES.map((spec) => ({
|
|
78
|
+
id: spec.id,
|
|
79
|
+
title: spec.title,
|
|
80
|
+
surface: "mcp",
|
|
81
|
+
check(ctx) {
|
|
82
|
+
return repoMatchesFinding(ctx, spec.category, (detail, count) => ({
|
|
83
|
+
ruleId: spec.id,
|
|
84
|
+
severity: spec.severity,
|
|
85
|
+
title: spec.title,
|
|
86
|
+
message: spec.message(count),
|
|
87
|
+
detail,
|
|
88
|
+
...(spec.fixHint ? { fixHint: spec.fixHint } : {}),
|
|
89
|
+
}));
|
|
90
|
+
},
|
|
91
|
+
}));
|
|
92
|
+
const e101 = {
|
|
93
|
+
id: "E101",
|
|
94
|
+
title: "legacy SDK generation",
|
|
95
|
+
surface: "mcp",
|
|
96
|
+
check(ctx) {
|
|
97
|
+
if (!ctx.repo?.sdk)
|
|
98
|
+
return [];
|
|
99
|
+
const findings = [];
|
|
100
|
+
const hasRc = ctx.repo.sdk.some((s) => s.generation === "rc");
|
|
101
|
+
if (hasRc)
|
|
102
|
+
return [];
|
|
103
|
+
for (const s of ctx.repo.sdk) {
|
|
104
|
+
findings.push({
|
|
105
|
+
ruleId: "E101",
|
|
106
|
+
severity: "warn",
|
|
107
|
+
title: "legacy SDK generation",
|
|
108
|
+
message: `depends on ${s.package}${s.range ? `@${s.range}` : ""} (pre-${RC} line)`,
|
|
109
|
+
detail: s.language === "ts"
|
|
110
|
+
? `the ${RC} revision ships as the new @modelcontextprotocol/server package (2.x beta since 2026-07)`
|
|
111
|
+
: `the ${RC} revision ships as mcp 2.x (pre-releases on PyPI since 2026-06)`,
|
|
112
|
+
fixHint: "upgrade to the 2.x SDK line to get stateless transport and MRTR support",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return findings;
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
const e104 = {
|
|
119
|
+
id: "E104",
|
|
120
|
+
title: "uses deprecated MCP Logging",
|
|
121
|
+
surface: "mcp",
|
|
122
|
+
check(ctx) {
|
|
123
|
+
if (ctx.intro.capabilities && "logging" in ctx.intro.capabilities) {
|
|
124
|
+
return [
|
|
125
|
+
{
|
|
126
|
+
ruleId: "E104",
|
|
127
|
+
severity: "warn",
|
|
128
|
+
title: "uses deprecated MCP Logging",
|
|
129
|
+
message: `server declares the logging capability; MCP Logging is deprecated in ${RC} (SEP-2577) and logging/setLevel is removed`,
|
|
130
|
+
fixHint: "log to stderr (stdio) or use OpenTelemetry; per-request level arrives via _meta io.modelcontextprotocol/logLevel",
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
return repoMatchesFinding(ctx, "logging", (detail, count) => ({
|
|
135
|
+
ruleId: "E104",
|
|
136
|
+
severity: "warn",
|
|
137
|
+
title: "uses deprecated MCP Logging",
|
|
138
|
+
message: `source references MCP logging APIs (${matchStr(count)}); deprecated in ${RC} (SEP-2577)`,
|
|
139
|
+
detail,
|
|
140
|
+
fixHint: "log to stderr (stdio) or use OpenTelemetry",
|
|
141
|
+
}));
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
const e105 = {
|
|
145
|
+
id: "E105",
|
|
146
|
+
title: "requires the removed initialize handshake",
|
|
147
|
+
surface: "mcp",
|
|
148
|
+
check(ctx) {
|
|
149
|
+
const bare = ctx.probes?.bareToolsList;
|
|
150
|
+
if (!isOutcome(bare))
|
|
151
|
+
return [];
|
|
152
|
+
if (looksLikeInitGate(bare)) {
|
|
153
|
+
return [
|
|
154
|
+
{
|
|
155
|
+
ruleId: "E105",
|
|
156
|
+
severity: "warn",
|
|
157
|
+
title: "requires the removed initialize handshake",
|
|
158
|
+
message: `the server rejected a bare stateless tools/list as not initialized (${bare.errorMessage ?? `code ${bare.errorCode}`})`,
|
|
159
|
+
detail: `${RC} removes initialize and sessions; RC servers answer bare requests carrying version info in _meta. Whether an answering server is RC-conformant is judged separately (E107 resultType, E118 cache fields, E106 server/discover).`,
|
|
160
|
+
fixHint: "upgrade to a 2.x SDK, or accept requests without a prior initialize",
|
|
161
|
+
},
|
|
162
|
+
];
|
|
163
|
+
}
|
|
164
|
+
if (bare.kind === "timeout") {
|
|
165
|
+
return [
|
|
166
|
+
{
|
|
167
|
+
ruleId: "E105",
|
|
168
|
+
severity: "info",
|
|
169
|
+
title: "statelessness not verified",
|
|
170
|
+
message: `the bare stateless tools/list timed out; this is inconclusive, not proof of a handshake requirement (the server may simply be slow to start)`,
|
|
171
|
+
fixHint: "re-run with a larger --timeout; if it still times out, confirm the server answers requests without a prior initialize",
|
|
172
|
+
},
|
|
173
|
+
];
|
|
174
|
+
}
|
|
175
|
+
return [];
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
const e106 = {
|
|
179
|
+
id: "E106",
|
|
180
|
+
title: "server/discover not implemented",
|
|
181
|
+
surface: "mcp",
|
|
182
|
+
check(ctx) {
|
|
183
|
+
const d = ctx.probes?.serverDiscover;
|
|
184
|
+
if (!d || !("supported" in d) || d.supported)
|
|
185
|
+
return [];
|
|
186
|
+
return [
|
|
187
|
+
{
|
|
188
|
+
ruleId: "E106",
|
|
189
|
+
severity: "warn",
|
|
190
|
+
title: "server/discover not implemented",
|
|
191
|
+
message: `server/discover is not implemented (${d.errorMessage ?? "method not found"})`,
|
|
192
|
+
detail: `${RC} servers MUST implement server/discover (SEP-2575) to advertise versions, capabilities, and identity; clients also use it as the back-compat probe.`,
|
|
193
|
+
fixHint: "the 2.x SDKs implement server/discover for you",
|
|
194
|
+
},
|
|
195
|
+
];
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
const e107 = {
|
|
199
|
+
id: "E107",
|
|
200
|
+
title: "results missing resultType",
|
|
201
|
+
surface: "mcp",
|
|
202
|
+
check(ctx) {
|
|
203
|
+
if (ctx.probes?.resultTypePresent !== false)
|
|
204
|
+
return [];
|
|
205
|
+
return [
|
|
206
|
+
{
|
|
207
|
+
ruleId: "E107",
|
|
208
|
+
severity: "info",
|
|
209
|
+
title: "results missing resultType",
|
|
210
|
+
message: `results do not carry the resultType field required in ${RC} ("complete" | "input_required")`,
|
|
211
|
+
detail: "the RC requires resultType on every result (SEP-2322); on list results the value must be \"complete\". RC clients treat missing resultType from earlier-protocol servers as \"complete\", so this is informational until you upgrade.",
|
|
212
|
+
},
|
|
213
|
+
];
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
const e109 = {
|
|
217
|
+
id: "E109",
|
|
218
|
+
title: "auth still on deprecated DCR",
|
|
219
|
+
surface: "mcp",
|
|
220
|
+
check(ctx) {
|
|
221
|
+
const auth = ctx.probes?.httpAuth;
|
|
222
|
+
if (!auth?.required)
|
|
223
|
+
return [];
|
|
224
|
+
if (auth.cimdSupported === true)
|
|
225
|
+
return [];
|
|
226
|
+
if (auth.dcrRegistrationEndpoint === true) {
|
|
227
|
+
return [
|
|
228
|
+
{
|
|
229
|
+
ruleId: "E109",
|
|
230
|
+
severity: "info",
|
|
231
|
+
title: "authorization server advertises DCR",
|
|
232
|
+
message: "the authorization server offers Dynamic Client Registration; the 2026-07-28 revision prefers Client ID Metadata Documents (CIMD). This does not mean your server uses DCR, only that the AS advertises it.",
|
|
233
|
+
detail: "heuristic and informational: CIMD support was not detected in the authorization-server metadata, a field few servers publish yet",
|
|
234
|
+
},
|
|
235
|
+
];
|
|
236
|
+
}
|
|
237
|
+
return [
|
|
238
|
+
{
|
|
239
|
+
ruleId: "E109",
|
|
240
|
+
severity: "info",
|
|
241
|
+
title: "auth metadata not assessable",
|
|
242
|
+
message: "server requires authentication; OAuth metadata could not be fully assessed (checks skipped)",
|
|
243
|
+
},
|
|
244
|
+
];
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
const e112 = {
|
|
248
|
+
id: "E112",
|
|
249
|
+
title: "nondeterministic tools/list order",
|
|
250
|
+
surface: "mcp",
|
|
251
|
+
check(ctx) {
|
|
252
|
+
if (ctx.probes?.toolsOrderDeterministic !== false)
|
|
253
|
+
return [];
|
|
254
|
+
return [
|
|
255
|
+
{
|
|
256
|
+
ruleId: "E112",
|
|
257
|
+
severity: "warn",
|
|
258
|
+
title: "nondeterministic tools/list order",
|
|
259
|
+
message: "tool order differed across two fresh connections; deterministic ordering enables prompt-cache hits in RC hosts",
|
|
260
|
+
fixHint: "sort tools stably before returning them",
|
|
261
|
+
},
|
|
262
|
+
];
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
const e113 = {
|
|
266
|
+
id: "E113",
|
|
267
|
+
title: "no Server Card",
|
|
268
|
+
surface: "mcp",
|
|
269
|
+
check(ctx) {
|
|
270
|
+
const sc = ctx.probes?.serverCard;
|
|
271
|
+
if (!sc || !("found" in sc) || sc.found)
|
|
272
|
+
return [];
|
|
273
|
+
return [
|
|
274
|
+
{
|
|
275
|
+
ruleId: "E113",
|
|
276
|
+
severity: "info",
|
|
277
|
+
title: "no Server Card",
|
|
278
|
+
message: `no Server Card found at ${sc.url ?? "/.well-known/mcp"} (heuristic path; the Server Card working group is standardizing discovery metadata)`,
|
|
279
|
+
},
|
|
280
|
+
];
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
const e116 = {
|
|
284
|
+
id: "E116",
|
|
285
|
+
title: "stdout noise on stdio transport",
|
|
286
|
+
surface: "mcp",
|
|
287
|
+
check(ctx) {
|
|
288
|
+
if (!ctx.probes?.stdoutNoise)
|
|
289
|
+
return [];
|
|
290
|
+
return [
|
|
291
|
+
{
|
|
292
|
+
ruleId: "E116",
|
|
293
|
+
severity: "warn",
|
|
294
|
+
title: "stdout noise on stdio transport",
|
|
295
|
+
message: "server printed non-JSON output on stdout; stdio transport requires clean line-delimited JSON-RPC",
|
|
296
|
+
detail: `observed: ${ctx.probes.stdoutNoise}`,
|
|
297
|
+
fixHint: "write logs and banners to stderr, never stdout",
|
|
298
|
+
},
|
|
299
|
+
];
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
const e117 = {
|
|
303
|
+
id: "E117",
|
|
304
|
+
title: "only legacy HTTP+SSE transport",
|
|
305
|
+
surface: "mcp",
|
|
306
|
+
check(ctx) {
|
|
307
|
+
if (ctx.intro.httpTransport !== "sse-legacy")
|
|
308
|
+
return [];
|
|
309
|
+
return [
|
|
310
|
+
{
|
|
311
|
+
ruleId: "E117",
|
|
312
|
+
severity: "warn",
|
|
313
|
+
title: "only legacy HTTP+SSE transport",
|
|
314
|
+
message: "Streamable HTTP failed and only the legacy HTTP+SSE transport worked; HTTP+SSE is deprecated in the 2026-07-28 revision",
|
|
315
|
+
fixHint: "migrate to Streamable HTTP (single POST endpoint)",
|
|
316
|
+
},
|
|
317
|
+
];
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
const e118 = {
|
|
321
|
+
id: "E118",
|
|
322
|
+
title: "missing cache fields (ttlMs, cacheScope)",
|
|
323
|
+
surface: "mcp",
|
|
324
|
+
check(ctx) {
|
|
325
|
+
if (ctx.probes?.cacheFieldsPresent !== false)
|
|
326
|
+
return [];
|
|
327
|
+
return [
|
|
328
|
+
{
|
|
329
|
+
ruleId: "E118",
|
|
330
|
+
severity: "warn",
|
|
331
|
+
title: "missing cache fields (ttlMs, cacheScope)",
|
|
332
|
+
message: `tools/list result omits ttlMs and/or cacheScope, which ${RC} requires on list and resource-read results (SEP-2549, CacheableResult)`,
|
|
333
|
+
detail: "required on tools/list, prompts/list, resources/list, resources/read, resources/templates/list, and server/discover; cacheScope is \"public\" or \"private\"",
|
|
334
|
+
fixHint: "return ttlMs and cacheScope on these results so clients can cache and stop polling; the 2.x SDKs add them for you",
|
|
335
|
+
},
|
|
336
|
+
];
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
/* ------------------------- quality (E12x-E13x) ------------------------- */
|
|
340
|
+
const PLACEHOLDER_RE = /^(todo|tbd|fixme|test|desc(ription)?|\.+|-+)$/i;
|
|
341
|
+
const e121 = {
|
|
342
|
+
id: "E121",
|
|
343
|
+
title: "missing or thin tool description",
|
|
344
|
+
surface: "mcp",
|
|
345
|
+
check(ctx) {
|
|
346
|
+
const bad = [];
|
|
347
|
+
for (const t of ctx.intro.tools) {
|
|
348
|
+
const d = (t.description ?? "").trim();
|
|
349
|
+
if (!d || PLACEHOLDER_RE.test(d)) {
|
|
350
|
+
bad.push({
|
|
351
|
+
ruleId: "E121",
|
|
352
|
+
severity: "error",
|
|
353
|
+
title: "missing or thin tool description",
|
|
354
|
+
message: `tool "${t.name}": description is ${d ? `a placeholder ("${d}")` : "missing"}`,
|
|
355
|
+
target: t.name,
|
|
356
|
+
fixHint: "models choose tools by description; say what it does, when to use it, and what it returns",
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
else if (d.length < 20) {
|
|
360
|
+
bad.push({
|
|
361
|
+
ruleId: "E121",
|
|
362
|
+
severity: "warn",
|
|
363
|
+
title: "missing or thin tool description",
|
|
364
|
+
message: `tool "${t.name}": description is only ${d.length} chars ("${d}")`,
|
|
365
|
+
target: t.name,
|
|
366
|
+
fixHint: "expand to cover purpose, when to use, and return shape",
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const { shown, more } = capList(bad, 8);
|
|
371
|
+
if (more) {
|
|
372
|
+
shown.push({
|
|
373
|
+
ruleId: "E121",
|
|
374
|
+
severity: "warn",
|
|
375
|
+
title: "missing or thin tool description",
|
|
376
|
+
message: `...and ${more} more tools with missing or thin descriptions`,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
return shown;
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
const QUALITY_AXES = [
|
|
383
|
+
{ key: "length 40..600", test: (d) => d.length >= 40 && d.length <= 600 },
|
|
384
|
+
{ key: "says when to use it", test: (d) => /\buse (this|it|when|for)\b|\bwhen (the|you|a|to)\b|\buse cases?\b/i.test(d) },
|
|
385
|
+
{
|
|
386
|
+
key: "parameters documented",
|
|
387
|
+
test: (_d, t) => {
|
|
388
|
+
const props = t.inputSchema?.properties;
|
|
389
|
+
if (!props)
|
|
390
|
+
return true;
|
|
391
|
+
const entries = Object.values(props);
|
|
392
|
+
if (!entries.length)
|
|
393
|
+
return true;
|
|
394
|
+
const documented = entries.filter((p) => typeof p?.description === "string" && p.description.length > 0).length;
|
|
395
|
+
return documented / entries.length >= 0.7;
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
{ key: "mentions the result", test: (d) => /\breturns?\b|\bresponse\b|\boutput\b|\bresult\b/i.test(d) },
|
|
399
|
+
];
|
|
400
|
+
const e122 = {
|
|
401
|
+
id: "E122",
|
|
402
|
+
title: "weak description quality",
|
|
403
|
+
surface: "mcp",
|
|
404
|
+
check(ctx) {
|
|
405
|
+
const findings = [];
|
|
406
|
+
for (const t of ctx.intro.tools) {
|
|
407
|
+
const d = (t.description ?? "").trim();
|
|
408
|
+
if (!d || d.length < 20)
|
|
409
|
+
continue; // E121 territory
|
|
410
|
+
const failed = QUALITY_AXES.filter((a) => !a.test(d, t)).map((a) => a.key);
|
|
411
|
+
if (failed.length >= 3) {
|
|
412
|
+
findings.push({
|
|
413
|
+
ruleId: "E122",
|
|
414
|
+
severity: "warn",
|
|
415
|
+
title: "weak description quality",
|
|
416
|
+
message: `tool "${t.name}": description misses ${failed.length}/4 quality axes (${failed.join("; ")})`,
|
|
417
|
+
target: t.name,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return capList(findings, 5).shown;
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
const DESTRUCTIVE_NAME_RE = /\b(delete|remove|drop|purge|destroy|overwrite|truncate|erase|wipe|revoke|reset|format|deploy|kill)\b/i;
|
|
425
|
+
const e123 = {
|
|
426
|
+
id: "E123",
|
|
427
|
+
title: "missing tool annotations",
|
|
428
|
+
surface: "mcp",
|
|
429
|
+
check(ctx) {
|
|
430
|
+
const tools = ctx.intro.tools;
|
|
431
|
+
if (!tools.length)
|
|
432
|
+
return [];
|
|
433
|
+
const withAnnotations = tools.filter((t) => t.annotations && Object.keys(t.annotations).length > 0);
|
|
434
|
+
const findings = [];
|
|
435
|
+
if (withAnnotations.length === 0) {
|
|
436
|
+
findings.push({
|
|
437
|
+
ruleId: "E123",
|
|
438
|
+
severity: "warn",
|
|
439
|
+
title: "missing tool annotations",
|
|
440
|
+
message: `none of ${tools.length} tools declare annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint)`,
|
|
441
|
+
fixHint: "annotations let hosts gate confirmations and parallelize safely",
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
const risky = tools.filter((t) => DESTRUCTIVE_NAME_RE.test(t.name) && !(t.annotations && "destructiveHint" in t.annotations));
|
|
445
|
+
for (const t of capList(risky, 5).shown) {
|
|
446
|
+
findings.push({
|
|
447
|
+
ruleId: "E123",
|
|
448
|
+
severity: "info",
|
|
449
|
+
title: "missing tool annotations",
|
|
450
|
+
message: `tool "${t.name}" looks destructive but has no destructiveHint annotation`,
|
|
451
|
+
target: t.name,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
return findings;
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
const e124 = {
|
|
458
|
+
id: "E124",
|
|
459
|
+
title: "schema issues",
|
|
460
|
+
surface: "mcp",
|
|
461
|
+
check(ctx) {
|
|
462
|
+
const findings = [];
|
|
463
|
+
let undocumented = 0;
|
|
464
|
+
let totalParams = 0;
|
|
465
|
+
for (const t of ctx.intro.tools) {
|
|
466
|
+
const schema = t.inputSchema;
|
|
467
|
+
const props = schema?.properties ?? {};
|
|
468
|
+
for (const [pname, p] of Object.entries(props)) {
|
|
469
|
+
totalParams++;
|
|
470
|
+
if (!p?.description)
|
|
471
|
+
undocumented++;
|
|
472
|
+
if (Array.isArray(p?.enum) && p.enum.length > 50) {
|
|
473
|
+
findings.push({
|
|
474
|
+
ruleId: "E124",
|
|
475
|
+
severity: "warn",
|
|
476
|
+
title: "schema issues",
|
|
477
|
+
message: `tool "${t.name}" parameter "${pname}": enum with ${p.enum.length} values inflates every prompt`,
|
|
478
|
+
target: t.name,
|
|
479
|
+
fixHint: "accept a string and validate server-side, or document values in a resource",
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if (totalParams > 5 && undocumented / totalParams > 0.3) {
|
|
485
|
+
findings.push({
|
|
486
|
+
ruleId: "E124",
|
|
487
|
+
severity: "warn",
|
|
488
|
+
title: "schema issues",
|
|
489
|
+
message: `${undocumented} of ${totalParams} parameters lack descriptions`,
|
|
490
|
+
fixHint: "parameter descriptions are how models fill arguments correctly",
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
return capList(findings, 5).shown;
|
|
494
|
+
},
|
|
495
|
+
};
|
|
496
|
+
const e125 = {
|
|
497
|
+
id: "E125",
|
|
498
|
+
title: "tool count inflates context",
|
|
499
|
+
surface: "mcp",
|
|
500
|
+
check(ctx) {
|
|
501
|
+
const n = ctx.intro.tools.length;
|
|
502
|
+
if (n > 60) {
|
|
503
|
+
return [
|
|
504
|
+
{
|
|
505
|
+
ruleId: "E125",
|
|
506
|
+
severity: "warn",
|
|
507
|
+
title: "tool count inflates context",
|
|
508
|
+
message: `${n} tools is far past the point where selection accuracy and context cost degrade`,
|
|
509
|
+
fixHint: "split into focused servers, or rely on hosts' deferred tool loading",
|
|
510
|
+
},
|
|
511
|
+
];
|
|
512
|
+
}
|
|
513
|
+
if (n > 30) {
|
|
514
|
+
return [
|
|
515
|
+
{
|
|
516
|
+
ruleId: "E125",
|
|
517
|
+
severity: "warn",
|
|
518
|
+
title: "tool count inflates context",
|
|
519
|
+
message: `${n} tools; large toolsets inflate context and confuse tool routing`,
|
|
520
|
+
fixHint: "consider splitting into focused servers",
|
|
521
|
+
},
|
|
522
|
+
];
|
|
523
|
+
}
|
|
524
|
+
return [];
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
const e126 = {
|
|
528
|
+
id: "E126",
|
|
529
|
+
title: "naming inconsistencies",
|
|
530
|
+
surface: "mcp",
|
|
531
|
+
check(ctx) {
|
|
532
|
+
const findings = [];
|
|
533
|
+
const names = ctx.intro.tools.map((t) => t.name);
|
|
534
|
+
const snake = names.filter((n) => /^[a-z0-9]+(_[a-z0-9]+)+$/.test(n)).length;
|
|
535
|
+
const camel = names.filter((n) => /^[a-z]+[A-Z]/.test(n)).length;
|
|
536
|
+
if (snake > 0 && camel > 0) {
|
|
537
|
+
findings.push({
|
|
538
|
+
ruleId: "E126",
|
|
539
|
+
severity: "info",
|
|
540
|
+
title: "naming inconsistencies",
|
|
541
|
+
message: `mixed naming styles across tools (${snake} snake_case, ${camel} camelCase)`,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
for (const n of names.filter((n) => n.length > 64).slice(0, 3)) {
|
|
545
|
+
findings.push({
|
|
546
|
+
ruleId: "E126",
|
|
547
|
+
severity: "warn",
|
|
548
|
+
title: "naming inconsistencies",
|
|
549
|
+
message: `tool name "${truncate(n, 70)}" exceeds 64 chars (breaks stricter clients)`,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
return findings;
|
|
553
|
+
},
|
|
554
|
+
};
|
|
555
|
+
const e127 = {
|
|
556
|
+
id: "E127",
|
|
557
|
+
title: "heavyweight tool definition",
|
|
558
|
+
surface: "mcp",
|
|
559
|
+
check(ctx) {
|
|
560
|
+
if (!ctx.weigh)
|
|
561
|
+
return [];
|
|
562
|
+
const heavy = ctx.weigh.perTool.filter((t) => t.tokens.claudeStyle > 800);
|
|
563
|
+
return capList(heavy, 5).shown.map((t) => ({
|
|
564
|
+
ruleId: "E127",
|
|
565
|
+
severity: "warn",
|
|
566
|
+
title: "heavyweight tool definition",
|
|
567
|
+
message: `tool "${t.name}" definition is ~${formatTokens(t.tokens.claudeStyle)} tokens (description ${formatTokens(t.descriptionTokens)}, schema ${formatTokens(t.schemaTokens)})`,
|
|
568
|
+
target: t.name,
|
|
569
|
+
fixHint: "move examples and long docs into resources; trim schema descriptions",
|
|
570
|
+
}));
|
|
571
|
+
},
|
|
572
|
+
};
|
|
573
|
+
const e128 = {
|
|
574
|
+
id: "E128",
|
|
575
|
+
title: "total context tax",
|
|
576
|
+
surface: "mcp",
|
|
577
|
+
check(ctx) {
|
|
578
|
+
if (!ctx.weigh)
|
|
579
|
+
return [];
|
|
580
|
+
const total = ctx.weigh.totals.claudeStyle;
|
|
581
|
+
// warn, not error: this is an estimated absolute (see docs/METHODOLOGY.md).
|
|
582
|
+
// Hard CI failure on cost belongs to the user-chosen --max-tokens/--diff gate.
|
|
583
|
+
if (total > 25000) {
|
|
584
|
+
return [
|
|
585
|
+
{
|
|
586
|
+
ruleId: "E128",
|
|
587
|
+
severity: "warn",
|
|
588
|
+
title: "total context tax",
|
|
589
|
+
message: `tool definitions alone are ~${formatTokens(total)} tokens (~${((total / 200000) * 100).toFixed(1)}% of a 200k window, estimated) before any work happens`,
|
|
590
|
+
fixHint: "trim descriptions/schemas, split the server, or rely on deferred tool loading",
|
|
591
|
+
},
|
|
592
|
+
];
|
|
593
|
+
}
|
|
594
|
+
if (total > 10000) {
|
|
595
|
+
return [
|
|
596
|
+
{
|
|
597
|
+
ruleId: "E128",
|
|
598
|
+
severity: "warn",
|
|
599
|
+
title: "total context tax",
|
|
600
|
+
message: `tool definitions are ~${formatTokens(total)} tokens (~${((total / 200000) * 100).toFixed(1)}% of a 200k window)`,
|
|
601
|
+
fixHint: "run \`efaimo weigh\` for the per-tool breakdown",
|
|
602
|
+
},
|
|
603
|
+
];
|
|
604
|
+
}
|
|
605
|
+
return [];
|
|
606
|
+
},
|
|
607
|
+
};
|
|
608
|
+
const e130 = {
|
|
609
|
+
id: "E130",
|
|
610
|
+
title: "possible instruction injection",
|
|
611
|
+
surface: "mcp",
|
|
612
|
+
check(ctx) {
|
|
613
|
+
const findings = [];
|
|
614
|
+
for (const t of ctx.intro.tools) {
|
|
615
|
+
if (findings.length >= 10)
|
|
616
|
+
break;
|
|
617
|
+
if (t.description) {
|
|
618
|
+
findings.push(...scanTextForInjection(t.description, {
|
|
619
|
+
ruleId: "E130",
|
|
620
|
+
where: `tool "${t.name}" description`,
|
|
621
|
+
cap: 10 - findings.length,
|
|
622
|
+
}));
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (ctx.intro.instructions && findings.length < 10) {
|
|
626
|
+
findings.push(...scanTextForInjection(ctx.intro.instructions, {
|
|
627
|
+
ruleId: "E130",
|
|
628
|
+
where: "server instructions",
|
|
629
|
+
cap: 10 - findings.length,
|
|
630
|
+
}));
|
|
631
|
+
}
|
|
632
|
+
return findings;
|
|
633
|
+
},
|
|
634
|
+
};
|
|
635
|
+
export const MCP_RULES = [
|
|
636
|
+
e101, e104, e105, e106, e107, e109, e112, e113, e116, e117, e118,
|
|
637
|
+
...repoMatchRules,
|
|
638
|
+
e121, e122, e123, e124, e125, e126, e127, e128, e130,
|
|
639
|
+
];
|
|
640
|
+
/** E101-E118 are 2026-07-28 readiness rules: reported as a migration diff, not graded. */
|
|
641
|
+
export function isReadinessRuleId(id) {
|
|
642
|
+
return /^E1(0|1)\d$/.test(id);
|
|
643
|
+
}
|