efaimo 0.1.1 → 0.1.2
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 +66 -5
- package/README.md +1 -1
- package/SKILL.md +3 -3
- package/dist/check/check.js +8 -0
- package/dist/cli.js +17 -5
- package/dist/clients/introspect.js +10 -2
- package/dist/reporters/pretty.js +3 -1
- package/dist/rules/injection.d.ts +1 -2
- package/dist/rules/injection.js +0 -11
- package/dist/rules/mcp/index.js +21 -1
- package/dist/rules/skill/index.js +5 -3
- package/dist/targets/repoScan.js +0 -0
- package/dist/testing/harness.d.ts +1 -0
- package/dist/testing/harness.js +38 -4
- package/dist/testing/openaiRunner.js +15 -8
- package/dist/testing/stats.d.ts +0 -2
- package/dist/testing/stats.js +0 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/METHODOLOGY.md +1 -1
- package/docs/RULES.md +2 -2
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,65 @@ All notable changes to efaimo are documented here. Format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project uses
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [0.1.2] - 2026-08-03
|
|
8
|
+
|
|
9
|
+
The 0.1.2 code backlog: edge-case fixes surfaced by the five-agent review, none
|
|
10
|
+
of which changes a published number or the documented command surface, plus the
|
|
11
|
+
security and CI hardening already staged.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- `check --mcp <directory>` with no source files and no MCP SDK dependency now
|
|
16
|
+
fails instead of returning a clean A(100): an empty scan is a failure, never a
|
|
17
|
+
pass.
|
|
18
|
+
- S101: a skill with no frontmatter reports its missing `name` and `description`
|
|
19
|
+
as errors, so a total absence no longer grades higher than a broken one.
|
|
20
|
+
- E105: a server that exits on a bare stateless `tools/list` is flagged. The
|
|
21
|
+
crash outcome was unhandled, so a crashing server looked more migrated than
|
|
22
|
+
one that answers with a not-initialized error.
|
|
23
|
+
- E124: the "N of M parameters lack descriptions" summary is no longer dropped
|
|
24
|
+
by the display cap on schemas with five or more oversized enums.
|
|
25
|
+
- S106: the escape check is lexical (the resolved path), not a `..` substring,
|
|
26
|
+
so a file merely named `notes..md` is not flagged as escaping.
|
|
27
|
+
- A Poetry `mcp = "^2.0"` constraint in `pyproject.toml` is read as the 2.x
|
|
28
|
+
line, not legacy; the version regex had captured the `=` assignment.
|
|
29
|
+
- `weigh --diff` with a skill baseline fails with a clear message instead of a
|
|
30
|
+
raw TypeError; a server baseline is required.
|
|
31
|
+
- `--max-tokens` and `--allow-increase` reject non-positive / negative values
|
|
32
|
+
instead of turning the gate into an always-fail with a nonsense message.
|
|
33
|
+
- `efaimo test --live` against an OpenAI o-series reasoning model no longer
|
|
34
|
+
sends an unsupported `temperature` (a 400) and gives it token headroom.
|
|
35
|
+
- The live test harness retries a transient 429 or 5xx with backoff instead of
|
|
36
|
+
discarding every already-paid trial on the first blip.
|
|
37
|
+
- The test report prints a clean note instead of "95% interval NaN to NaN" when
|
|
38
|
+
an arm produces no scoreable trial.
|
|
39
|
+
- A dead stdio target fails in about one `--timeout`, not two: the legacy
|
|
40
|
+
handshake and the stateless fallback now share the budget.
|
|
41
|
+
- Internal: dropped a dead exported constant and the unused per-pattern
|
|
42
|
+
`severity` on the injection heuristics (every hit is info + ungraded by
|
|
43
|
+
design).
|
|
44
|
+
|
|
45
|
+
### Security
|
|
46
|
+
|
|
47
|
+
- The lockfile was re-resolved so the dev/CI tree carries
|
|
48
|
+
`@hono/node-server` 2.0.12 instead of the 1.19.x line that
|
|
49
|
+
GHSA-frvp-7c67-39w9 covers (moderate, path traversal in `serve-static`, in
|
|
50
|
+
the half of the MCP SDK this CLI never executes). A fresh `npx efaimo`
|
|
51
|
+
install already resolved the patched line; this aligns the committed tree
|
|
52
|
+
with what users actually get, and `pnpm audit --prod` is clean again.
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- `@modelcontextprotocol/sdk` floor moved within-range from ^1.29.0 to ^1.30.0
|
|
57
|
+
(what a fresh install resolves anyway). The full suite (136 tests) is green.
|
|
58
|
+
- CI: the reference-server smoke is pinned to
|
|
59
|
+
`@modelcontextprotocol/server-everything@2026.7.4`. Per-commit CI should be
|
|
60
|
+
deterministic; news about the reference server's latest belongs to a
|
|
61
|
+
scheduled run, not to a push.
|
|
62
|
+
- CI: `spec-drift.yml` and the example workflow now pin actions by commit SHA
|
|
63
|
+
like the other workflows, and the example declares least-privilege
|
|
64
|
+
`permissions`.
|
|
65
|
+
|
|
7
66
|
## [0.1.1] - 2026-08-02
|
|
8
67
|
|
|
9
68
|
Everything below was on `main` and unpublished. That gap was itself the
|
|
@@ -32,7 +91,7 @@ that notices the next revision by itself.
|
|
|
32
91
|
hostile server could put ESC[1A ESC[2K in a tool name and rewrite the grade
|
|
33
92
|
line printed above its own finding, so the reader saw "grade A (100), 0
|
|
34
93
|
errors" while the contradiction was erased. `--no-color` did not help; that
|
|
35
|
-
only suppresses
|
|
94
|
+
only suppresses colors efaimo adds.
|
|
36
95
|
- `.env` loading is restricted to `ANTHROPIC_API_KEY` and `OPENAI_API_KEY`.
|
|
37
96
|
It previously set ANY key from a working-directory `.env`, so a cloned
|
|
38
97
|
repository could set `NODE_TLS_REJECT_UNAUTHORIZED=0` and silently disable
|
|
@@ -40,8 +99,8 @@ that notices the next revision by itself.
|
|
|
40
99
|
key.
|
|
41
100
|
- Skill references that resolve outside the skill directory are no longer read
|
|
42
101
|
by `weigh`. S106 already warned about them, but only after the read.
|
|
43
|
-
- `SKILL.md` is read through the same
|
|
44
|
-
|
|
102
|
+
- `SKILL.md` is read through a capped read (2MB) on the same guarded path that
|
|
103
|
+
caps referenced files at 512KB, instead of an uncapped read.
|
|
45
104
|
- The published composite Action no longer interpolates its inputs into a bash
|
|
46
105
|
`run:` body, which was script injection in the consumer's runner.
|
|
47
106
|
|
|
@@ -101,7 +160,7 @@ that notices the next revision by itself.
|
|
|
101
160
|
extend `CacheableResult`, so that surface is required now, is measured now,
|
|
102
161
|
and the message names whichever surface failed.
|
|
103
162
|
|
|
104
|
-
## [0.1.0]
|
|
163
|
+
## [0.1.0] - 2026-07-18
|
|
105
164
|
|
|
106
165
|
First release.
|
|
107
166
|
|
|
@@ -114,7 +173,7 @@ First release.
|
|
|
114
173
|
broken or auth-gated server is skipped with a reason, not fatal.
|
|
115
174
|
`--out`/`--diff`/`--max-tokens`/`--allow-increase` for CI budget gates,
|
|
116
175
|
`--badge` for a shields endpoint.
|
|
117
|
-
- `efaimo check --mcp`: a quality grade (E121-E130: descriptions, schemas,
|
|
176
|
+
- `efaimo check --mcp`: a quality grade (E121-E128, E130: descriptions, schemas,
|
|
118
177
|
annotations, cost) plus a separate, ungraded 2026-07-28 migration diff
|
|
119
178
|
(E101-E118: what the stateless spec breaks and how to fix it, each item naming
|
|
120
179
|
its SEP). Speaks both the legacy handshake and bare stateless requests, so
|
|
@@ -138,5 +197,7 @@ First release.
|
|
|
138
197
|
Documented rule set (`docs/RULES.md`), token methodology (`docs/METHODOLOGY.md`),
|
|
139
198
|
and integration guide (`docs/INTEGRATIONS.md`).
|
|
140
199
|
|
|
200
|
+
[unreleased]: https://github.com/efaimo-ai/efaimo/compare/v0.1.2...HEAD
|
|
201
|
+
[0.1.2]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.1.2
|
|
141
202
|
[0.1.1]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.1.1
|
|
142
203
|
[0.1.0]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -53,7 +53,7 @@ hygiene. Run it on a folder and every skill gets its own grade, not one aggregat
|
|
|
53
53
|
**We graded 36 public Agent Skills**: every skill in `anthropics/skills`,
|
|
54
54
|
`anthropics/claude-cookbooks`, and `obra/superpowers`, at pinned commits. 97%
|
|
55
55
|
score an A, but even this curated set has real issues: Anthropic's own
|
|
56
|
-
`claude-api` scores a C with an over-limit description and ~18k-token
|
|
56
|
+
`claude-api` scores a C (73) with an over-limit description and ~18k-token
|
|
57
57
|
instructions, and the median skill's instructions run ~1,632 tokens loaded on
|
|
58
58
|
every trigger. Full report, the corpus manifest, and the two commands that
|
|
59
59
|
reproduce it: [the Skills Quality Index](./research/skills-index/REPORT.md).
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: efaimo
|
|
|
3
3
|
description: Audit MCP servers and Agent Skills for quality and context-window cost. Use when the user wants to check an MCP server's token cost, lint a skill, verify 2026-07-28 MCP spec readiness, or add an efaimo badge or CI gate.
|
|
4
4
|
license: Apache-2.0
|
|
5
5
|
metadata:
|
|
6
|
-
version: "0.1.
|
|
6
|
+
version: "0.1.2"
|
|
7
7
|
homepage: "https://efaimo.ai"
|
|
8
8
|
---
|
|
9
9
|
|
|
@@ -26,8 +26,8 @@ you would be willing to run yourself. A remote target (`https://...`) is not
|
|
|
26
26
|
executed and is the safe option for something you do not trust. `--client`
|
|
27
27
|
reads the MCP config files in the current directory as well as your user
|
|
28
28
|
config, so running it inside a repository you did not write means starting the
|
|
29
|
-
servers that repository configured.
|
|
30
|
-
|
|
29
|
+
servers that repository configured. Until 2026-08-02 this warning lived only in
|
|
30
|
+
`SECURITY.md`, which was the wrong way round: the policy is where humans look,
|
|
31
31
|
and this file is where agents take orders.
|
|
32
32
|
|
|
33
33
|
- **Weigh context cost of an MCP server**
|
package/dist/check/check.js
CHANGED
|
@@ -50,6 +50,14 @@ export async function checkMcpTarget(target, opts = {}) {
|
|
|
50
50
|
}
|
|
51
51
|
export function checkMcpRepoOnly(repoPath, label) {
|
|
52
52
|
const repo = scanRepo(repoPath);
|
|
53
|
+
// An empty result is a failure, never a pass. With no source files read and no
|
|
54
|
+
// dependency manifest naming an MCP SDK, there was nothing to examine here, and
|
|
55
|
+
// a clean A(100) would be a grade for a directory we never looked inside. Fail
|
|
56
|
+
// loudly, the way checkSkillSet does for a missing SKILL.md.
|
|
57
|
+
if (repo.filesScanned === 0 && !repo.sdk) {
|
|
58
|
+
throw new Error(`no MCP server source found under "${repoPath}": no code files and no SDK dependency. ` +
|
|
59
|
+
`Point --mcp at the server's run command, or at a directory that contains its source.`);
|
|
60
|
+
}
|
|
53
61
|
const emptyIntro = {
|
|
54
62
|
targetLabel: label,
|
|
55
63
|
transport: "stdio",
|
package/dist/cli.js
CHANGED
|
@@ -82,12 +82,18 @@ function seconds(value, label = "--timeout") {
|
|
|
82
82
|
fail(`${label} must be a positive number of seconds, got "${value}"`);
|
|
83
83
|
return n;
|
|
84
84
|
}
|
|
85
|
-
function parseNumberOpt(value, label) {
|
|
85
|
+
function parseNumberOpt(value, label, bound) {
|
|
86
86
|
if (value === undefined)
|
|
87
87
|
return undefined;
|
|
88
88
|
const n = Number(value);
|
|
89
89
|
if (!Number.isFinite(n))
|
|
90
90
|
fail(`${label} must be a number, got "${value}"`);
|
|
91
|
+
// A budget or a percentage below its floor is not a stricter gate, it is a
|
|
92
|
+
// nonsensical one: `--max-tokens -5` turned "over budget" into always-true
|
|
93
|
+
// with a negative ceiling in the message. Reject it at the boundary.
|
|
94
|
+
if (bound && (bound.exclusive ? n <= bound.min : n < bound.min)) {
|
|
95
|
+
fail(`${label} must be ${bound.exclusive ? "greater than" : "at least"} ${bound.min}, got "${value}"`);
|
|
96
|
+
}
|
|
91
97
|
return n;
|
|
92
98
|
}
|
|
93
99
|
function anthropicKeyFor(opts) {
|
|
@@ -122,8 +128,8 @@ program
|
|
|
122
128
|
colorSetup(opts);
|
|
123
129
|
windowSetup(opts);
|
|
124
130
|
const timeoutMs = seconds(opts.timeout) * 1000;
|
|
125
|
-
const maxTokens = parseNumberOpt(opts.maxTokens, "--max-tokens");
|
|
126
|
-
const allowIncreasePct = parseNumberOpt(opts.allowIncrease, "--allow-increase");
|
|
131
|
+
const maxTokens = parseNumberOpt(opts.maxTokens, "--max-tokens", { min: 0, exclusive: true });
|
|
132
|
+
const allowIncreasePct = parseNumberOpt(opts.allowIncrease, "--allow-increase", { min: 0 });
|
|
127
133
|
const anthropic = anthropicKeyFor(opts);
|
|
128
134
|
const targets = [];
|
|
129
135
|
if (opts.client) {
|
|
@@ -239,8 +245,14 @@ program
|
|
|
239
245
|
fail("--diff works on a single MCP server result");
|
|
240
246
|
const baselineRaw = JSON.parse(fs.readFileSync(opts.diff, "utf8"));
|
|
241
247
|
const baseline = ("data" in baselineRaw && baselineRaw.data ? baselineRaw.data : baselineRaw);
|
|
242
|
-
|
|
243
|
-
|
|
248
|
+
// A skill weigh baseline also carries a `totals` object, so a bare
|
|
249
|
+
// truthiness check passed it through and diffServerWeigh then died on the
|
|
250
|
+
// missing tool array with a raw TypeError. A server baseline is the one
|
|
251
|
+
// whose totals hold the three serializations; require the Claude-style
|
|
252
|
+
// total specifically so a skill baseline is rejected with a clear message.
|
|
253
|
+
if (!baseline?.totals || typeof baseline.totals.claudeStyle !== "number") {
|
|
254
|
+
fail(`"${opts.diff}" is not an MCP server weigh baseline; write one with: efaimo weigh <server> --out ${opts.diff}`);
|
|
255
|
+
}
|
|
244
256
|
const d = diffServerWeigh(baseline, single);
|
|
245
257
|
console.log("");
|
|
246
258
|
console.log(opts.md ? renderDiffMarkdown(d) : renderDiffPretty(d, { maxTokens, allowIncreasePct }));
|
|
@@ -23,14 +23,22 @@ export async function introspectServer(target, opts = {}) {
|
|
|
23
23
|
stderr: "pipe",
|
|
24
24
|
});
|
|
25
25
|
transport = stdio;
|
|
26
|
+
// Split the budget across the two attempts so a dead server fails in about
|
|
27
|
+
// one timeout, not two: the legacy handshake got the whole timeout AND the
|
|
28
|
+
// stateless fallback then re-waited the whole timeout again, so `--timeout 8`
|
|
29
|
+
// took ~16s wall. The handshake takes the larger share; the fallback gets
|
|
30
|
+
// what is left, with a floor so a genuinely stateless server still has time
|
|
31
|
+
// to answer a bare request.
|
|
32
|
+
const startedAt = Date.now();
|
|
26
33
|
try {
|
|
27
|
-
await withTimeout(client.connect(stdio), timeoutMs, "connect (stdio)");
|
|
34
|
+
await withTimeout(client.connect(stdio), Math.max(1, Math.round(timeoutMs * 0.6)), "connect (stdio)");
|
|
28
35
|
}
|
|
29
36
|
catch (e) {
|
|
30
37
|
await safeClose(client, stdio);
|
|
31
38
|
// A 2026-07-28 stateless server has no initialize, so the SDK handshake
|
|
32
39
|
// fails by design; try bare stateless requests before giving up.
|
|
33
|
-
const
|
|
40
|
+
const remaining = Math.max(timeoutMs - (Date.now() - startedAt), Math.round(timeoutMs * 0.25));
|
|
41
|
+
const rc = await introspectStateless(target, { timeoutMs: remaining });
|
|
34
42
|
if (rc)
|
|
35
43
|
return rc;
|
|
36
44
|
throw connectError(e, target.label, stderrTail);
|
package/dist/reporters/pretty.js
CHANGED
|
@@ -103,7 +103,9 @@ export function renderTestReportPretty(r) {
|
|
|
103
103
|
` with skill ${r.withSkill.passes}/${r.withSkill.scored} pass (${r.withSkill.passRate.toFixed(0)}%)`,
|
|
104
104
|
` without skill ${r.withoutSkill.passes}/${r.withoutSkill.scored} pass (${r.withoutSkill.passRate.toFixed(0)}%)`,
|
|
105
105
|
` delta ${paint(pc.bold, `${sign}${r.deltaPoints} points`)} ${paint(verdictColor, r.verdict)}`,
|
|
106
|
-
|
|
106
|
+
Number.isFinite(r.ci.lo)
|
|
107
|
+
? ` significance p = ${r.p < 0.0001 ? "<0.0001" : r.p.toFixed(4)} 95% CI ${r.ci.lo >= 0 ? "+" : ""}${r.ci.lo} to ${r.ci.hi >= 0 ? "+" : ""}${r.ci.hi} points`
|
|
108
|
+
: ` significance not computable: an arm produced no scoreable trial`,
|
|
107
109
|
"",
|
|
108
110
|
];
|
|
109
111
|
for (const n of r.notes)
|
package/dist/rules/injection.js
CHANGED
|
@@ -9,19 +9,16 @@ export const INJECTION_PATTERNS = [
|
|
|
9
9
|
{
|
|
10
10
|
key: "override-instructions",
|
|
11
11
|
re: /ignore\s+(?:all\s+|any\s+)?(?:previous|prior|earlier|above)\s+(?:instructions|messages|rules|prompts)/i,
|
|
12
|
-
severity: "error",
|
|
13
12
|
label: "attempts to override prior instructions",
|
|
14
13
|
},
|
|
15
14
|
{
|
|
16
15
|
key: "hide-from-user",
|
|
17
16
|
re: /do\s+not\s+(?:tell|reveal|mention|inform|show|disclose)\b[\s\S]{0,40}\b(?:user|human|operator)/i,
|
|
18
|
-
severity: "error",
|
|
19
17
|
label: "instructs the agent to hide behavior from the user",
|
|
20
18
|
},
|
|
21
19
|
{
|
|
22
20
|
key: "act-without-consent",
|
|
23
21
|
re: /without\s+(?:telling|asking|notifying|informing)\s+(?:the\s+)?(?:user|human)/i,
|
|
24
|
-
severity: "error",
|
|
25
22
|
label: "instructs the agent to act without user consent",
|
|
26
23
|
},
|
|
27
24
|
{
|
|
@@ -29,51 +26,43 @@ export const INJECTION_PATTERNS = [
|
|
|
29
26
|
// "post" and "token" dropped: they match API documentation ("Token counting
|
|
30
27
|
// POST /v1/...") far more than exfiltration.
|
|
31
28
|
re: /\b(?:api[_-]?key|secret|password|credential)s?\b[\s\S]{0,40}\b(?:send|forward|exfiltrat|upload|leak|transmit|include\s+it)\b/i,
|
|
32
|
-
severity: "error",
|
|
33
29
|
label: "references sending credentials or secrets somewhere",
|
|
34
30
|
},
|
|
35
31
|
{
|
|
36
32
|
key: "sensitive-file-read",
|
|
37
33
|
re: /\b(?:read|open|cat|print|upload)\b[\s\S]{0,40}(?:\.env\b|\.ssh\b|id_rsa|credentials|\.aws\b|\.npmrc\b)/i,
|
|
38
|
-
severity: "error",
|
|
39
34
|
label: "references reading sensitive local files",
|
|
40
35
|
},
|
|
41
36
|
{
|
|
42
37
|
key: "disable-safety",
|
|
43
38
|
re: /\b(?:disable|bypass|ignore|remove)\s+(?:safety|guardrails?|filters?|restrictions?|policy|policies)\b/i,
|
|
44
|
-
severity: "error",
|
|
45
39
|
label: "asks to bypass safety or policy",
|
|
46
40
|
},
|
|
47
41
|
{
|
|
48
42
|
key: "cross-tool-steering",
|
|
49
43
|
re: /before\s+(?:using|calling|running)\b[^.?!]{0,40}?\btools?\b[\s,]{1,4}(?:always\s+|first\s+|please\s+)?(?:call|run|use|fetch|invoke)\b/i,
|
|
50
|
-
severity: "warn",
|
|
51
44
|
label: "steers the agent to call another tool first (cross-tool steering)",
|
|
52
45
|
},
|
|
53
46
|
{
|
|
54
47
|
key: "templated-exfil-url",
|
|
55
48
|
// Bounded runs (no adjacent unbounded same-class quantifiers) to avoid ReDoS.
|
|
56
49
|
re: /https?:\/\/[^\s"'<>]{1,150}[?&][^\s"'<>]{0,150}(?:\$\{[^}]{1,60}\}|\{\{[^}]{1,60}\}\}|\{[^}]{1,60}\})/,
|
|
57
|
-
severity: "warn",
|
|
58
50
|
label: "URL with a templated query parameter (possible exfiltration channel)",
|
|
59
51
|
},
|
|
60
52
|
{
|
|
61
53
|
key: "zero-width-chars",
|
|
62
54
|
// eslint-disable-next-line no-control-regex
|
|
63
55
|
re: /[]/,
|
|
64
|
-
severity: "warn",
|
|
65
56
|
label: "zero-width or invisible characters present",
|
|
66
57
|
},
|
|
67
58
|
{
|
|
68
59
|
key: "hidden-html-comment",
|
|
69
60
|
re: /<!--[^>]{0,240}\b(?:must|always|never|call|run|fetch|send|ignore)\b[^>]{0,240}-->/i,
|
|
70
|
-
severity: "warn",
|
|
71
61
|
label: "HTML comment containing imperative instructions (hidden from rendered view)",
|
|
72
62
|
},
|
|
73
63
|
{
|
|
74
64
|
key: "persona-override",
|
|
75
65
|
re: /\byou\s+are\s+now\b|\bpretend\s+to\s+be\b|\bnew\s+system\s+prompt\b/i,
|
|
76
|
-
severity: "warn",
|
|
77
66
|
label: "persona or system-prompt override language",
|
|
78
67
|
},
|
|
79
68
|
];
|
package/dist/rules/mcp/index.js
CHANGED
|
@@ -166,6 +166,23 @@ const e105 = {
|
|
|
166
166
|
},
|
|
167
167
|
];
|
|
168
168
|
}
|
|
169
|
+
if (bare.kind === "exit") {
|
|
170
|
+
// A crash on the bare request is not a timeout and not a clean answer: the
|
|
171
|
+
// server did not serve a stateless tools/list, so a 2026-07-28 client
|
|
172
|
+
// cannot talk to it. Left unhandled, this outcome produced NO finding, so
|
|
173
|
+
// a crashing server looked one item MORE migrated than one that answers
|
|
174
|
+
// with a not-initialized error.
|
|
175
|
+
return [
|
|
176
|
+
{
|
|
177
|
+
ruleId: "E105",
|
|
178
|
+
severity: "warn",
|
|
179
|
+
title: "requires the removed initialize handshake",
|
|
180
|
+
message: `the server process exited on a bare stateless tools/list (${bare.errorMessage ?? "no reply"}), so it does not answer requests without a prior initialize`,
|
|
181
|
+
detail: `${SPEC} removes initialize and sessions; a server that exits on a bare request cannot serve a ${SPEC} client. A server that answers, even with an error, is judged by E106/E107/E118 instead.`,
|
|
182
|
+
fixHint: "upgrade to a 2.x SDK, or answer requests without a prior initialize instead of exiting",
|
|
183
|
+
},
|
|
184
|
+
];
|
|
185
|
+
}
|
|
169
186
|
if (bare.kind === "timeout") {
|
|
170
187
|
return [
|
|
171
188
|
{
|
|
@@ -526,7 +543,10 @@ const e124 = {
|
|
|
526
543
|
}
|
|
527
544
|
}
|
|
528
545
|
if (totalParams > 5 && undocumented / totalParams > 0.3) {
|
|
529
|
-
|
|
546
|
+
// Prepend, not append: capList keeps the first 5, and on the worst schemas
|
|
547
|
+
// (5+ oversized enums) an appended summary was exactly the line that got
|
|
548
|
+
// dropped. The aggregate is the headline, so it leads.
|
|
549
|
+
findings.unshift({
|
|
530
550
|
ruleId: "E124",
|
|
531
551
|
severity: "warn",
|
|
532
552
|
title: "schema issues",
|
|
@@ -16,8 +16,10 @@ const s101 = {
|
|
|
16
16
|
const push = (severity, message, fixHint) => findings.push({ ruleId: "S101", severity, title: "frontmatter does not match the spec", message, target: label, fixHint });
|
|
17
17
|
if (skill.parseError) {
|
|
18
18
|
push("error", `${skill.file}: ${skill.parseError}`, "SKILL.md must start with a --- YAML frontmatter block");
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
// No early return on empty frontmatter. Returning here after one error let
|
|
20
|
+
// a skill with NO frontmatter grade higher than one with BROKEN frontmatter
|
|
21
|
+
// (the required-field checks below never ran), so total absence outscored a
|
|
22
|
+
// typo by two errors. Absent fields ARE missing fields; report them too.
|
|
21
23
|
}
|
|
22
24
|
const name = skill.frontmatter.name;
|
|
23
25
|
if (typeof name !== "string" || !name.trim()) {
|
|
@@ -254,7 +256,7 @@ const s106 = {
|
|
|
254
256
|
fixHint: "agents will fail mid-task when they try to open it",
|
|
255
257
|
});
|
|
256
258
|
}
|
|
257
|
-
else if (ref.
|
|
259
|
+
else if (ref.escapes) {
|
|
258
260
|
findings.push({
|
|
259
261
|
ruleId: "S106",
|
|
260
262
|
severity: "warn",
|
package/dist/targets/repoScan.js
CHANGED
|
Binary file
|
|
@@ -63,4 +63,5 @@ export declare function armSystems(scenario: Scenario): {
|
|
|
63
63
|
withoutSkill: string | undefined;
|
|
64
64
|
withSkill: string;
|
|
65
65
|
};
|
|
66
|
+
export declare function withRetry(runner: Runner, tries?: number, baseDelayMs?: number): Runner;
|
|
66
67
|
export declare function runScenario(scenario: Scenario, runner: Runner): Promise<TestReport>;
|
package/dist/testing/harness.js
CHANGED
|
@@ -71,6 +71,32 @@ async function judgeOne(runner, scenario, answer) {
|
|
|
71
71
|
return "fail";
|
|
72
72
|
return "unparseable";
|
|
73
73
|
}
|
|
74
|
+
// A live run makes trials * 2 arms * 2 (answer + judge) API calls. One
|
|
75
|
+
// transient 429 or 5xx among them used to throw and discard every trial already
|
|
76
|
+
// paid for. Retry the transient failures with backoff; a non-429 4xx (bad key,
|
|
77
|
+
// unknown model) is a real error and is not retried.
|
|
78
|
+
function isTransient(err) {
|
|
79
|
+
const m = String(err?.message ?? err);
|
|
80
|
+
return (/\b(429|500|502|503|504)\b/.test(m) ||
|
|
81
|
+
/timeout|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|fetch failed|network/i.test(m));
|
|
82
|
+
}
|
|
83
|
+
export function withRetry(runner, tries = 4, baseDelayMs = 500) {
|
|
84
|
+
return async (req) => {
|
|
85
|
+
let lastErr;
|
|
86
|
+
for (let attempt = 0; attempt < tries; attempt++) {
|
|
87
|
+
try {
|
|
88
|
+
return await runner(req);
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
lastErr = e;
|
|
92
|
+
if (attempt === tries - 1 || !isTransient(e))
|
|
93
|
+
throw e;
|
|
94
|
+
await new Promise((r) => setTimeout(r, baseDelayMs * 2 ** attempt));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
throw lastErr;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
74
100
|
async function runArm(runner, scenario, system) {
|
|
75
101
|
let passes = 0;
|
|
76
102
|
let unparseable = 0;
|
|
@@ -95,8 +121,11 @@ async function runArm(runner, scenario, system) {
|
|
|
95
121
|
}
|
|
96
122
|
export async function runScenario(scenario, runner) {
|
|
97
123
|
const systems = armSystems(scenario);
|
|
98
|
-
|
|
99
|
-
|
|
124
|
+
// Wrap once so both the subject answers and the judge calls survive a
|
|
125
|
+
// transient blip instead of throwing away the whole (paid-for) scenario.
|
|
126
|
+
const r = withRetry(runner);
|
|
127
|
+
const withoutSkill = await runArm(r, scenario, systems.withoutSkill);
|
|
128
|
+
const withSkill = await runArm(r, scenario, systems.withSkill);
|
|
100
129
|
const deltaPoints = Math.round((withSkill.passRate - withoutSkill.passRate) * 10) / 10;
|
|
101
130
|
// The verdict is a significance test, not a threshold on the gap.
|
|
102
131
|
//
|
|
@@ -108,9 +137,14 @@ export async function runScenario(scenario, runner) {
|
|
|
108
137
|
// measuring sample size more than it was measuring the skill.
|
|
109
138
|
const p = fisherExactTwoSided(withSkill.passes, withSkill.scored, withoutSkill.passes, withoutSkill.scored);
|
|
110
139
|
const ci = deltaInterval(withSkill.passes, withSkill.scored, withoutSkill.passes, withoutSkill.scored);
|
|
140
|
+
// With no scoreable trial in an arm the pass rate and its interval are
|
|
141
|
+
// 0/0 = NaN; say so plainly instead of printing "NaN to NaN points".
|
|
142
|
+
const bothScored = withSkill.scored > 0 && withoutSkill.scored > 0;
|
|
111
143
|
const notes = [
|
|
112
|
-
|
|
113
|
-
|
|
144
|
+
bothScored
|
|
145
|
+
? `${scenario.trials} trials per arm. Two-sided Fisher exact p = ${p < 0.0001 ? "<0.0001" : p.toFixed(4)}; ` +
|
|
146
|
+
`95% interval on the delta ${ci.lo >= 0 ? "+" : ""}${ci.lo} to ${ci.hi >= 0 ? "+" : ""}${ci.hi} points.`
|
|
147
|
+
: `${scenario.trials} trials per arm. An arm produced no scoreable trial, so no p-value or delta interval can be computed.`,
|
|
114
148
|
];
|
|
115
149
|
const unscored = withSkill.unparseable + withoutSkill.unparseable;
|
|
116
150
|
if (unscored) {
|
|
@@ -8,20 +8,27 @@ export function openaiRunner(apiKey) {
|
|
|
8
8
|
if (req.system)
|
|
9
9
|
messages.push({ role: "system", content: req.system });
|
|
10
10
|
messages.push({ role: "user", content: req.user });
|
|
11
|
+
// o-series reasoning models (o1, o3, o4, ...) reject any non-default
|
|
12
|
+
// temperature with a 400, and a chat-sized 1024 cap starves them: their
|
|
13
|
+
// hidden reasoning tokens count against max_completion_tokens, so a small
|
|
14
|
+
// cap can return empty content. So omit temperature and give them headroom.
|
|
15
|
+
const isReasoning = /^o[0-9]/i.test(req.model);
|
|
16
|
+
const body = {
|
|
17
|
+
model: req.model,
|
|
18
|
+
messages,
|
|
19
|
+
max_completion_tokens: isReasoning ? 8192 : 1024,
|
|
20
|
+
};
|
|
21
|
+
// temperature: 0 for the judge, 1 for the subject (see
|
|
22
|
+
// RunnerRequest.deterministic), but only where the model accepts it.
|
|
23
|
+
if (!isReasoning)
|
|
24
|
+
body.temperature = req.deterministic ? 0 : 1;
|
|
11
25
|
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
12
26
|
method: "POST",
|
|
13
27
|
headers: {
|
|
14
28
|
"content-type": "application/json",
|
|
15
29
|
authorization: `Bearer ${apiKey}`,
|
|
16
30
|
},
|
|
17
|
-
|
|
18
|
-
// RunnerRequest.deterministic in harness.ts.
|
|
19
|
-
body: JSON.stringify({
|
|
20
|
-
model: req.model,
|
|
21
|
-
temperature: req.deterministic ? 0 : 1,
|
|
22
|
-
messages,
|
|
23
|
-
max_completion_tokens: 1024,
|
|
24
|
-
}),
|
|
31
|
+
body: JSON.stringify(body),
|
|
25
32
|
redirect: "error",
|
|
26
33
|
signal: AbortSignal.timeout(60000),
|
|
27
34
|
});
|
package/dist/testing/stats.d.ts
CHANGED
|
@@ -28,5 +28,3 @@ export declare function deltaInterval(withPass: number, withTrials: number, with
|
|
|
28
28
|
lo: number;
|
|
29
29
|
hi: number;
|
|
30
30
|
};
|
|
31
|
-
/** The smallest per-arm trial count at which a total separation (n/n vs 0/n) reaches p < 0.05. */
|
|
32
|
-
export declare const MIN_TRIALS_FOR_SIGNIFICANCE = 4;
|
package/dist/testing/stats.js
CHANGED
|
@@ -87,5 +87,3 @@ export function deltaInterval(withPass, withTrials, withoutPass, withoutTrials)
|
|
|
87
87
|
const hi = d + Math.sqrt((w.hi - p1) ** 2 + (p2 - o.lo) ** 2);
|
|
88
88
|
return { lo: Math.round(Math.max(-1, lo) * 1000) / 10, hi: Math.round(Math.min(1, hi) * 1000) / 10 };
|
|
89
89
|
}
|
|
90
|
-
/** The smallest per-arm trial count at which a total separation (n/n vs 0/n) reaches p < 0.05. */
|
|
91
|
-
export const MIN_TRIALS_FOR_SIGNIFICANCE = 4;
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Kept in sync with package.json by test/meta.test.ts. */
|
|
2
|
-
export declare const VERSION = "0.1.
|
|
2
|
+
export declare const VERSION = "0.1.2";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Kept in sync with package.json by test/meta.test.ts. */
|
|
2
|
-
export const VERSION = "0.1.
|
|
2
|
+
export const VERSION = "0.1.2";
|
package/docs/METHODOLOGY.md
CHANGED
|
@@ -127,7 +127,7 @@ attached. The default is 20 trials per arm: below roughly that, a partial
|
|
|
127
127
|
result cannot reach significance at all.
|
|
128
128
|
|
|
129
129
|
Until 2026-08-02 the rule was `>= +15 points helps`, with a default of 5
|
|
130
|
-
trials. At that size 5/5 against 4/5 is +20 points and p = 1.0000.
|
|
130
|
+
trials. At that size 5/5 against 4/5 is +20 points and p = 1.0000.
|
|
131
131
|
|
|
132
132
|
Two things the judge does, which matter for reading a result:
|
|
133
133
|
|
package/docs/RULES.md
CHANGED
|
@@ -22,11 +22,11 @@ rules are heuristics and say so in their detail line.
|
|
|
22
22
|
|
|
23
23
|
| id | sev | what it catches | source of truth |
|
|
24
24
|
|---|---|---|---|
|
|
25
|
-
| E101 | warn | depends on a pre-`2026-07-28` SDK line (TS `@modelcontextprotocol/sdk` 1.x, Python `mcp` 1.x) instead of the 2.x
|
|
25
|
+
| E101 | warn | depends on a pre-`2026-07-28` SDK line (TS `@modelcontextprotocol/sdk` 1.x, Python `mcp` 1.x) instead of the stateless successors (TS `@modelcontextprotocol/server` 2.x, Python `mcp` 2.x) | changelog / SDK releases |
|
|
26
26
|
| E102 | warn | source uses **Sampling** (`sampling/createMessage`), deprecated (SEP-2577) | 2026-07-28 changelog |
|
|
27
27
|
| E103 | warn | source uses **Roots** (`roots/list`), deprecated (SEP-2577) | 2026-07-28 changelog |
|
|
28
28
|
| E104 | warn | server declares the **logging** capability or source uses MCP Logging, deprecated (SEP-2577); `logging/setLevel` is removed | 2026-07-28 changelog |
|
|
29
|
-
| E105 | warn | the server
|
|
29
|
+
| E105 | warn | the server rejects a bare stateless `tools/list` with a not-initialized error, or exits on it, so it still requires the removed `initialize` handshake. A timeout is reported as unverified statelessness rather than as this warn, and a server that answers (even with another error) is judged by E106/E107/E118 instead | SEP-2567 / stateless core |
|
|
30
30
|
| E106 | warn | `server/discover` is not implemented (MUST in 2026-07-28, SEP-2575) | 2026-07-28 changelog |
|
|
31
31
|
| E107 | info | results omit the required `resultType` field (`"complete"` \| `"input_required"`) | 2026-07-28 changelog |
|
|
32
32
|
| E108 | info | source relies on removed SSE resumability (`Last-Event-ID`) | 2026-07-28 changelog |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "efaimo",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Audit what your agent loads: quality and context cost for MCP servers and Agent Skills",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"prepublishOnly": "npm run typecheck && npm run build && npm run test"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
66
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
67
67
|
"commander": "^15.0.0",
|
|
68
68
|
"cross-spawn": "^7.0.6",
|
|
69
69
|
"gpt-tokenizer": "^3.4.0",
|