efaimo 0.3.0 → 0.5.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 +105 -3
- package/README.md +4 -0
- package/SKILL.md +1 -1
- package/dist/check/check.js +15 -0
- package/dist/clients/introspect.js +4 -4
- package/dist/core/types.d.ts +18 -0
- package/dist/mcp/server.d.ts +2 -2
- package/dist/mcp/server.js +71 -65
- package/dist/skills/parse.d.ts +6 -0
- package/dist/skills/parse.js +47 -3
- package/dist/util/misc.d.ts +20 -2
- package/dist/util/misc.js +8 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/RULES.md +2 -1
- package/package.json +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,98 @@ 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.5.0] - 2026-09-04
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **`efaimo mcp` is built on the 2.x SDK line and now satisfies the
|
|
12
|
+
specification this tool audits other servers against.** The 1.x line speaks
|
|
13
|
+
2025-11-25; it cannot answer `server/discover` and cannot carry the SEP-2549
|
|
14
|
+
cache fields, so the one MCP server this project ships failed two MUST-level
|
|
15
|
+
items of the 2026-07-28 revision, and `check --mcp` said so about it on every
|
|
16
|
+
run. The skill this project publishes about that migration opens with "upgrade
|
|
17
|
+
the SDK first". This is that.
|
|
18
|
+
|
|
19
|
+
Measured against the built server, before and after, with the same command:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
before 2026-07-28 readiness 3 items to migrate E106, E118, E107
|
|
23
|
+
after 2026-07-28 readiness clean, nothing to migrate
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Read off the wire rather than from our own tool as well: a modern opening now
|
|
27
|
+
answers `server/discover` with `supportedVersions`, `capabilities`,
|
|
28
|
+
`resultType: "complete"`, `ttlMs` and `cacheScope`, and `tools/list` carries
|
|
29
|
+
the same three fields. A 2025-era client that opens with `initialize` is still
|
|
30
|
+
served, unchanged, because `serveStdio` pins the era per connection rather
|
|
31
|
+
than making the server pick one.
|
|
32
|
+
|
|
33
|
+
- **The client half moved with it**, from `@modelcontextprotocol/sdk` to
|
|
34
|
+
`@modelcontextprotocol/client`. Doing only the server half would have left
|
|
35
|
+
both generations installed, and E101 stops firing the moment any 2.x package
|
|
36
|
+
is present, so the half-migration would have silenced the rule that was
|
|
37
|
+
telling the truth.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- **Two moderate advisories, by subtraction.** `qs` arrived through
|
|
42
|
+
`body-parser` through `express` through the 1.x SDK, which pulls a whole HTTP
|
|
43
|
+
and OAuth stack that a stdio server never speaks. `pnpm audit --prod` now
|
|
44
|
+
reports no known vulnerabilities, and production dependencies went from 30 to
|
|
45
|
+
13.
|
|
46
|
+
|
|
47
|
+
### Notes
|
|
48
|
+
|
|
49
|
+
- No rule changed, so no grade moved. `rulesVersion` is untouched and the
|
|
50
|
+
published skills index does not need regenerating.
|
|
51
|
+
- `check --skill`, `weigh`, `find` and `test` are byte-identical in behaviour;
|
|
52
|
+
the only thing that moved is what `efaimo mcp` puts on the wire.
|
|
53
|
+
|
|
54
|
+
## [0.4.0] - 2026-09-03
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
- **`check --skill <dir>` finds skills it used to walk past**.
|
|
59
|
+
Two causes, both silent. The walk never entered a directory whose name starts
|
|
60
|
+
with a dot, so `.claude/skills/<name>/`, which is where a project keeps its
|
|
61
|
+
own skills, was invisible; and its depth bound of 3 missed layouts one level
|
|
62
|
+
deeper such as `skills/custom_skills/<name>/`. On the public corpus this
|
|
63
|
+
found 34 skills where `scripts/skills-index.mjs` found 38, and both numbers
|
|
64
|
+
were reported with confidence.
|
|
65
|
+
|
|
66
|
+
Dot directories are now entered for skill discovery and nowhere else, with
|
|
67
|
+
`.git` still excluded. The depth bound is 6 and is measured rather than
|
|
68
|
+
picked: the deepest real skill in that corpus sits 4 directories below a
|
|
69
|
+
directory of repositories and 3 below a repository root, so six leaves two
|
|
70
|
+
levels of headroom while still keeping an accidental `check --skill /` from
|
|
71
|
+
walking a disk.
|
|
72
|
+
|
|
73
|
+
**And when the bound does bite, it says so.** `SkillSet.truncatedAt` lists
|
|
74
|
+
the directories the walk refused to enter, because a bound that truncates in
|
|
75
|
+
silence is the same failure as a check that examines nothing. On the public
|
|
76
|
+
corpus it fires 16 times, all of them XML schema trees inside the `xlsx` and
|
|
77
|
+
`pptx` skills, which is the bound doing its job audibly.
|
|
78
|
+
|
|
79
|
+
- **One walker instead of two**. `scripts/skills-index.mjs`
|
|
80
|
+
carried its own copy with different rules, which is why the two disagreed.
|
|
81
|
+
It now calls the CLI's discovery, so they cannot drift apart again, and it
|
|
82
|
+
passes the truncation warning through. The published July index regenerates
|
|
83
|
+
byte-identically under the shared walk, which is how this was checked.
|
|
84
|
+
|
|
85
|
+
### Added
|
|
86
|
+
|
|
87
|
+
- **S107: a filename one capitalisation away from a skill**.
|
|
88
|
+
Set-level and warn-level, so it moves no grade, in the same way S103 treats
|
|
89
|
+
a collision that belongs to a pair rather than to either member.
|
|
90
|
+
|
|
91
|
+
The spec names `SKILL.md` exactly and discovery matches it exactly, so
|
|
92
|
+
`skill.md` is not a skill here on any platform. It is not nothing anywhere:
|
|
93
|
+
a case-insensitive filesystem, the default on macOS and Windows, may hand it
|
|
94
|
+
to a host that opens it by name, so the same repository can carry a working
|
|
95
|
+
skill on the author's laptop and no skill at all in Linux CI. Nothing in
|
|
96
|
+
this tool would previously have mentioned such a file, which is the worst
|
|
97
|
+
property a near miss can have. Five exist in the public corpus.
|
|
98
|
+
|
|
7
99
|
## [0.3.0] - 2026-09-03
|
|
8
100
|
|
|
9
101
|
### Added
|
|
@@ -34,9 +126,17 @@ All notable changes to efaimo are documented here. Format follows
|
|
|
34
126
|
both runs scored lower.
|
|
35
127
|
|
|
36
128
|
Measured on the public skills corpus, 2026-07-17 pinned against 2026-09-03
|
|
37
|
-
HEAD, same ruleset both sides
|
|
38
|
-
+61.4% with one skill at 96.5%
|
|
39
|
-
worsened.
|
|
129
|
+
HEAD, same ruleset both sides, over the **32 skills `check --skill` pairs**:
|
|
130
|
+
metadata -0.8%, body +7.5%, referenced files +61.4% with one skill at 96.5%
|
|
131
|
+
of that movement; grades 0 improved and 4 worsened.
|
|
132
|
+
|
|
133
|
+
The population matters and is worth stating, because
|
|
134
|
+
`research/skills-delta-2026-09-03/REPORT.md` reports body **+7.0%** over
|
|
135
|
+
**36** paired skills for the same corpus on the same dates. Both are true.
|
|
136
|
+
`scripts/skills-index.mjs` finds four skills that `check --skill` does not,
|
|
137
|
+
for the walker reason in Known below, so the two are measuring slightly
|
|
138
|
+
different populations. Every other figure is identical between them; the
|
|
139
|
+
body percentage is the one the four missing skills move.
|
|
40
140
|
|
|
41
141
|
- **`efaimo find` takes several servers**. Pass more than one
|
|
42
142
|
and their catalogs are merged into one and measured together, each tool
|
|
@@ -355,6 +455,8 @@ First release.
|
|
|
355
455
|
Documented rule set (`docs/RULES.md`), token methodology (`docs/METHODOLOGY.md`),
|
|
356
456
|
and integration guide (`docs/INTEGRATIONS.md`).
|
|
357
457
|
|
|
458
|
+
[0.5.0]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.5.0
|
|
459
|
+
[0.4.0]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.4.0
|
|
358
460
|
[0.3.0]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.3.0
|
|
359
461
|
[0.2.0]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.2.0
|
|
360
462
|
[0.1.2]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.1.2
|
package/README.md
CHANGED
|
@@ -406,3 +406,7 @@ Built by [efaimo ai](https://efaimo.ai), open tooling for the space between host
|
|
|
406
406
|
and tools. `efaimo` is the flagship CLI; capabilities grow as subcommands under one
|
|
407
407
|
name. Apache-2.0. See [CONTRIBUTING.md](./CONTRIBUTING.md) and
|
|
408
408
|
[docs/RULES.md](./docs/RULES.md) to add a rule.
|
|
409
|
+
|
|
410
|
+
EFAIMO AI is a registered trademark of the publisher (KR 40-2585820, class 42).
|
|
411
|
+
The lowercase `efaimo ai` used throughout is the same mark; the package name and
|
|
412
|
+
the commands stay lowercase.
|
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, find out whether a search can single out a server's tools under deferred tool loading, or add an efaimo badge or CI gate.
|
|
4
4
|
license: Apache-2.0
|
|
5
5
|
metadata:
|
|
6
|
-
version: "0.
|
|
6
|
+
version: "0.5.0"
|
|
7
7
|
homepage: "https://efaimo.ai"
|
|
8
8
|
---
|
|
9
9
|
|
package/dist/check/check.js
CHANGED
|
@@ -85,5 +85,20 @@ export async function checkSkillSet(pathInput, label) {
|
|
|
85
85
|
return { name, dir: skill.dir, report: buildReport("skill", name, findings, []) };
|
|
86
86
|
});
|
|
87
87
|
const setFindings = set.skills.flatMap((skill) => runRules(setRules, { skill, set, weigh }));
|
|
88
|
+
// S107 is emitted here rather than by a SkillRule because its subject is a
|
|
89
|
+
// file that is NOT a skill: no rule that takes a parsed skill could ever see
|
|
90
|
+
// it. Set level and warn level on purpose, so it moves no grade, matching
|
|
91
|
+
// how S103 treats a collision that belongs to a pair rather than to either
|
|
92
|
+
// member.
|
|
93
|
+
for (const file of set.miscasedSkillFiles ?? []) {
|
|
94
|
+
setFindings.push({
|
|
95
|
+
ruleId: "S107",
|
|
96
|
+
severity: "warn",
|
|
97
|
+
title: "filename is one capitalisation away from a skill",
|
|
98
|
+
message: `"${path.relative(set.root, file)}" is not loaded here, because the spec names SKILL.md exactly. A case-insensitive filesystem (the macOS and Windows default) may still hand it to a host, so this can work on a laptop and be missing entirely in Linux CI.`,
|
|
99
|
+
target: path.relative(set.root, file),
|
|
100
|
+
fixHint: "rename it to SKILL.md, or delete it if it is not a skill",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
88
103
|
return { label, root: set.root, perSkill, setFindings, weigh };
|
|
89
104
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import crossSpawn from "cross-spawn";
|
|
2
|
-
import { Client } from "@modelcontextprotocol/
|
|
3
|
-
import { StdioClientTransport } from "@modelcontextprotocol/
|
|
4
|
-
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/
|
|
5
|
-
import { SSEClientTransport } from "@modelcontextprotocol/
|
|
2
|
+
import { Client } from "@modelcontextprotocol/client";
|
|
3
|
+
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
|
4
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
5
|
+
import { SSEClientTransport } from "@modelcontextprotocol/client";
|
|
6
6
|
import { introspectStateless } from "./stateless.js";
|
|
7
7
|
import { minimalChildEnv } from "../util/childEnv.js";
|
|
8
8
|
import { truncate, withTimeout } from "../util/misc.js";
|
package/dist/core/types.d.ts
CHANGED
|
@@ -339,6 +339,24 @@ export interface SkillInfo {
|
|
|
339
339
|
export interface SkillSet {
|
|
340
340
|
root: string;
|
|
341
341
|
skills: SkillInfo[];
|
|
342
|
+
/**
|
|
343
|
+
* Directories the walk refused to enter because it hit its depth bound.
|
|
344
|
+
*
|
|
345
|
+
* Present only when the bound actually bit. A truncated search that reports
|
|
346
|
+
* a short list and no reason is the same failure as a check that examines
|
|
347
|
+
* nothing, so the caller is given the means to say what it did not look at.
|
|
348
|
+
*/
|
|
349
|
+
truncatedAt?: string[];
|
|
350
|
+
/**
|
|
351
|
+
* Files one capitalisation away from being a skill, such as `skill.md`.
|
|
352
|
+
*
|
|
353
|
+
* The spec names `SKILL.md` exactly and discovery matches it exactly, so
|
|
354
|
+
* these are not skills here on any platform. They may still be skills to a
|
|
355
|
+
* host reading a case-insensitive filesystem, which is the default on macOS
|
|
356
|
+
* and Windows, so the same repository can work on a laptop and have no
|
|
357
|
+
* skills at all in Linux CI. Reported by S107.
|
|
358
|
+
*/
|
|
359
|
+
miscasedSkillFiles?: string[];
|
|
342
360
|
}
|
|
343
361
|
export interface GradeInfo {
|
|
344
362
|
score: number;
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export declare function buildMcpServer():
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
export declare function buildMcpServer(): McpServer;
|
|
3
3
|
export declare function runMcpServer(): Promise<void>;
|
package/dist/mcp/server.js
CHANGED
|
@@ -1,88 +1,94 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
3
|
+
import { z } from "zod";
|
|
4
4
|
import { checkSkillSet } from "../check/check.js";
|
|
5
5
|
import { weighSkills } from "../weigh/weigh.js";
|
|
6
6
|
import { findSkills } from "../skills/parse.js";
|
|
7
7
|
import { renderCheckMarkdown, renderSkillSetMarkdown, renderWeighMarkdown } from "../reporters/markdown.js";
|
|
8
8
|
import { VERSION } from "../version.js";
|
|
9
|
+
/**
|
|
10
|
+
* `efaimo mcp`: a small, read-only MCP server that exposes the skill checks to an
|
|
11
|
+
* agent, so it can lint or weigh a skill mid-session before committing it to
|
|
12
|
+
* context. Deliberately narrow: the tools only read files. They spawn no process
|
|
13
|
+
* and open no socket (unlike `check --mcp`, which connects to a live server), and
|
|
14
|
+
* `test` is not exposed at all because it spends tokens. That keeps the surface
|
|
15
|
+
* safe for an agent to call unattended.
|
|
16
|
+
*
|
|
17
|
+
* Built on the 2.x SDK line since 0.5.0. The 1.x line speaks 2025-11-25 and
|
|
18
|
+
* cannot answer `server/discover` or carry the SEP-2549 cache fields, so this
|
|
19
|
+
* server failed two MUST-level items of the specification the rest of this tool
|
|
20
|
+
* audits other servers against, and the skill this project publishes about that
|
|
21
|
+
* migration opens with "upgrade the SDK first". It was the only server we ship
|
|
22
|
+
* and the only one we had not migrated.
|
|
23
|
+
*
|
|
24
|
+
* `serveStdio` owns the era decision rather than this file: the opening exchange
|
|
25
|
+
* selects it, one instance is pinned for the connection, and a 2025-era client
|
|
26
|
+
* that opens with `initialize` is still served. Registering the tools once, on a
|
|
27
|
+
* factory that serves both eras, is the whole reason to use it instead of
|
|
28
|
+
* wiring a transport by hand.
|
|
29
|
+
*/
|
|
9
30
|
// Both tools only read local files, so the same hints apply: safe to call without
|
|
10
31
|
// a confirmation, safe to repeat, and not reaching out to any external service.
|
|
11
32
|
const READ_ONLY = { readOnlyHint: true, idempotentHint: true, openWorldHint: false };
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
33
|
+
// One shared shape. `.min(1)` is the schema saying what `requireString` used to
|
|
34
|
+
// say at runtime: an empty path is not a path. Under the 2.x registration the
|
|
35
|
+
// SDK validates against this before the handler runs, so the check moved from
|
|
36
|
+
// our code into the contract, which is where a client can also see it.
|
|
37
|
+
const pathInput = (what) => z.object({ path: z.string().min(1).describe(what) });
|
|
38
|
+
const CHECK_DESCRIPTION = "Lint an Agent Skill, or a folder of skills, for spec compliance, trigger quality, " +
|
|
39
|
+
"context-window cost, reference integrity, and injection hygiene. Returns a grade from " +
|
|
40
|
+
"A to F and the findings. Read-only: reads files only.";
|
|
41
|
+
const WEIGH_DESCRIPTION = "Measure the context-window token cost of an Agent Skill: the metadata loaded into every " +
|
|
42
|
+
"session and the body loaded when the skill triggers. Returns token counts per skill. " +
|
|
43
|
+
"Read-only: reads files only.";
|
|
44
|
+
const text = (s) => ({ content: [{ type: "text", text: s }] });
|
|
45
|
+
const failure = (e) => ({
|
|
46
|
+
content: [{ type: "text", text: `error: ${e instanceof Error ? e.message : String(e)}` }],
|
|
47
|
+
isError: true,
|
|
24
48
|
});
|
|
25
|
-
|
|
26
|
-
{
|
|
27
|
-
|
|
28
|
-
description:
|
|
29
|
-
|
|
30
|
-
"A to F and the findings. Read-only: reads files only.",
|
|
31
|
-
inputSchema: pathSchema("Path to a SKILL.md file or a directory that contains skills."),
|
|
49
|
+
export function buildMcpServer() {
|
|
50
|
+
const server = new McpServer({ name: "efaimo", version: VERSION }, { capabilities: { tools: {} } });
|
|
51
|
+
server.registerTool("efaimo_check_skill", {
|
|
52
|
+
description: CHECK_DESCRIPTION,
|
|
53
|
+
inputSchema: pathInput("Path to a SKILL.md file or a directory that contains skills."),
|
|
32
54
|
annotations: READ_ONLY,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const res = await checkSkillSet(
|
|
55
|
+
}, async ({ path }) => {
|
|
56
|
+
try {
|
|
57
|
+
const res = await checkSkillSet(path, path);
|
|
36
58
|
if (res.perSkill.length === 1 && res.setFindings.length === 0) {
|
|
37
|
-
return renderCheckMarkdown(res.perSkill[0].report);
|
|
59
|
+
return text(renderCheckMarkdown(res.perSkill[0].report));
|
|
38
60
|
}
|
|
39
|
-
return renderSkillSetMarkdown(res);
|
|
40
|
-
},
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
name: "efaimo_weigh_skill",
|
|
44
|
-
description: "Measure the context-window token cost of an Agent Skill: the metadata loaded into every " +
|
|
45
|
-
"session and the body loaded when the skill triggers. Returns token counts per skill. " +
|
|
46
|
-
"Read-only: reads files only.",
|
|
47
|
-
inputSchema: pathSchema("Path to a SKILL.md file or a directory that contains skills."),
|
|
48
|
-
annotations: READ_ONLY,
|
|
49
|
-
async run(args) {
|
|
50
|
-
const p = requireString(args, "path");
|
|
51
|
-
const set = findSkills(p);
|
|
52
|
-
if (!set.skills.length)
|
|
53
|
-
throw new Error(`no SKILL.md found under "${p}"`);
|
|
54
|
-
return renderWeighMarkdown(await weighSkills(set));
|
|
55
|
-
},
|
|
56
|
-
},
|
|
57
|
-
];
|
|
58
|
-
export function buildMcpServer() {
|
|
59
|
-
const server = new Server({ name: "efaimo", version: VERSION }, { capabilities: { tools: {} } });
|
|
60
|
-
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
|
61
|
-
tools: TOOLS.map((t) => ({
|
|
62
|
-
name: t.name,
|
|
63
|
-
description: t.description,
|
|
64
|
-
inputSchema: t.inputSchema,
|
|
65
|
-
annotations: t.annotations,
|
|
66
|
-
})),
|
|
67
|
-
}));
|
|
68
|
-
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
69
|
-
const tool = TOOLS.find((t) => t.name === req.params.name);
|
|
70
|
-
if (!tool) {
|
|
71
|
-
return { content: [{ type: "text", text: `unknown tool: ${req.params.name}` }], isError: true };
|
|
61
|
+
return text(renderSkillSetMarkdown(res));
|
|
72
62
|
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
return failure(e);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
server.registerTool("efaimo_weigh_skill", {
|
|
68
|
+
description: WEIGH_DESCRIPTION,
|
|
69
|
+
inputSchema: pathInput("Path to a SKILL.md file or a directory that contains skills."),
|
|
70
|
+
annotations: READ_ONLY,
|
|
71
|
+
}, async ({ path }) => {
|
|
73
72
|
try {
|
|
74
|
-
const
|
|
75
|
-
|
|
73
|
+
const set = findSkills(path);
|
|
74
|
+
if (!set.skills.length)
|
|
75
|
+
throw new Error(`no SKILL.md found under "${path}"`);
|
|
76
|
+
return text(renderWeighMarkdown(await weighSkills(set)));
|
|
76
77
|
}
|
|
77
78
|
catch (e) {
|
|
78
|
-
|
|
79
|
-
return { content: [{ type: "text", text: `error: ${message}` }], isError: true };
|
|
79
|
+
return failure(e);
|
|
80
80
|
}
|
|
81
81
|
});
|
|
82
82
|
return server;
|
|
83
83
|
}
|
|
84
84
|
export async function runMcpServer() {
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
// A factory, not an instance: `serveStdio` pins one per connection after the
|
|
86
|
+
// opening exchange tells it which era it is talking to.
|
|
87
|
+
serveStdio(() => buildMcpServer(), {
|
|
88
|
+
onerror: (e) => console.error(`efaimo mcp: ${e.message}`),
|
|
89
|
+
});
|
|
87
90
|
console.error(`efaimo mcp v${VERSION}: read-only skill tools ready on stdio`);
|
|
91
|
+
// stdin holds the process open; there is nothing to await. Returning a promise
|
|
92
|
+
// that never settles would only make the shutdown path harder to read.
|
|
93
|
+
await new Promise(() => { });
|
|
88
94
|
}
|
package/dist/skills/parse.d.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import type { SkillInfo, SkillSet } from "../core/types.js";
|
|
2
2
|
export declare function parseSkillFile(file: string): SkillInfo;
|
|
3
3
|
/** Resolve a path (SKILL.md file, skill dir, or a directory of skills) into a SkillSet. */
|
|
4
|
+
/**
|
|
5
|
+
* How deep skill discovery walks. See the note inside `findSkills`: measured
|
|
6
|
+
* against the public corpus, where the deepest real skill is four directories
|
|
7
|
+
* below a directory of repositories.
|
|
8
|
+
*/
|
|
9
|
+
export declare const SKILL_WALK_MAX_DEPTH = 6;
|
|
4
10
|
export declare function findSkills(input: string): SkillSet;
|
package/dist/skills/parse.js
CHANGED
|
@@ -129,6 +129,12 @@ export function parseSkillFile(file) {
|
|
|
129
129
|
return base;
|
|
130
130
|
}
|
|
131
131
|
/** Resolve a path (SKILL.md file, skill dir, or a directory of skills) into a SkillSet. */
|
|
132
|
+
/**
|
|
133
|
+
* How deep skill discovery walks. See the note inside `findSkills`: measured
|
|
134
|
+
* against the public corpus, where the deepest real skill is four directories
|
|
135
|
+
* below a directory of repositories.
|
|
136
|
+
*/
|
|
137
|
+
export const SKILL_WALK_MAX_DEPTH = 6;
|
|
132
138
|
export function findSkills(input) {
|
|
133
139
|
const abs = path.resolve(input);
|
|
134
140
|
const stat = fs.statSync(abs);
|
|
@@ -139,11 +145,49 @@ export function findSkills(input) {
|
|
|
139
145
|
if (fs.existsSync(direct)) {
|
|
140
146
|
return { root: abs, skills: [parseSkillFile(direct)] };
|
|
141
147
|
}
|
|
148
|
+
// Discovery is deliberate rather than incidental, because two walkers with
|
|
149
|
+
// different rules is what produced the 2026-09-03 defect: this found 34
|
|
150
|
+
// skills in a corpus where `scripts/skills-index.mjs` found 38, and both
|
|
151
|
+
// reported their own number with confidence.
|
|
152
|
+
//
|
|
153
|
+
// maxDepth 6 is measured, not picked. In the public corpus the deepest real
|
|
154
|
+
// skill sits 4 directories below a directory-of-repositories
|
|
155
|
+
// (`<corpus>/<repo>/skills/custom_skills/<name>/SKILL.md`) and 3 below a
|
|
156
|
+
// repository root. Six leaves two levels of headroom and keeps an accidental
|
|
157
|
+
// `check --skill /` from walking a disk. When the bound does bite it is
|
|
158
|
+
// reported rather than swallowed, in `truncatedAt`.
|
|
159
|
+
//
|
|
160
|
+
// Dot directories are entered here and nowhere else in this codebase:
|
|
161
|
+
// `.claude/skills/<name>/` is where a project keeps its own skills, so
|
|
162
|
+
// skipping them made this blind to the most common real layout. `.git` is
|
|
163
|
+
// still excluded by DEFAULT_SKIP_DIRS.
|
|
142
164
|
const skills = [];
|
|
143
|
-
|
|
144
|
-
|
|
165
|
+
const truncatedAt = [];
|
|
166
|
+
const miscased = [];
|
|
167
|
+
for (const f of walkFiles(abs, {
|
|
168
|
+
maxDepth: SKILL_WALK_MAX_DEPTH,
|
|
169
|
+
includeDotDirs: true,
|
|
170
|
+
onDepthLimit: (d) => truncatedAt.push(d),
|
|
171
|
+
})) {
|
|
172
|
+
const base = path.basename(f);
|
|
173
|
+
if (base === "SKILL.md")
|
|
145
174
|
skills.push(parseSkillFile(f));
|
|
175
|
+
// A file that is only a capitalisation away from being a skill. The spec
|
|
176
|
+
// names SKILL.md exactly, and this comparison is exact, so `skill.md` is
|
|
177
|
+
// invisible here on every platform. It is not invisible everywhere: a
|
|
178
|
+
// case-insensitive filesystem, which is the default on macOS and Windows,
|
|
179
|
+
// may hand it to a host that opens it by name, so the same repository has
|
|
180
|
+
// a working skill on the author's laptop and no skill at all in Linux CI.
|
|
181
|
+
// Nothing else in this tool would ever mention it, which is the worst
|
|
182
|
+
// property a near miss can have.
|
|
183
|
+
else if (base.toLowerCase() === "skill.md")
|
|
184
|
+
miscased.push(f);
|
|
146
185
|
}
|
|
147
186
|
skills.sort((a, b) => a.file.localeCompare(b.file));
|
|
148
|
-
return {
|
|
187
|
+
return {
|
|
188
|
+
root: abs,
|
|
189
|
+
skills,
|
|
190
|
+
...(truncatedAt.length ? { truncatedAt } : {}),
|
|
191
|
+
...(miscased.length ? { miscasedSkillFiles: miscased.sort() } : {}),
|
|
192
|
+
};
|
|
149
193
|
}
|
package/dist/util/misc.d.ts
CHANGED
|
@@ -3,10 +3,28 @@ export declare function parseCommandString(input: string): {
|
|
|
3
3
|
command: string;
|
|
4
4
|
args: string[];
|
|
5
5
|
};
|
|
6
|
-
export
|
|
6
|
+
export interface WalkOptions {
|
|
7
7
|
maxDepth?: number;
|
|
8
8
|
skipDirs?: Set<string>;
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Descend into directories whose name starts with a dot.
|
|
11
|
+
*
|
|
12
|
+
* Off by default, because most of them are caches and metadata. On for skill
|
|
13
|
+
* discovery, because `.claude/skills/<name>/` is where a project keeps its
|
|
14
|
+
* own skills and a tool that cannot see the most common real layout is not
|
|
15
|
+
* auditing anything. `DEFAULT_SKIP_DIRS` still excludes `.git`.
|
|
16
|
+
*/
|
|
17
|
+
includeDotDirs?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Called once for each directory not entered because `maxDepth` was reached.
|
|
20
|
+
*
|
|
21
|
+
* A bound that truncates in silence is the same failure as a check that
|
|
22
|
+
* examines nothing: the caller sees a short list and no reason for it. This
|
|
23
|
+
* lets the caller say so.
|
|
24
|
+
*/
|
|
25
|
+
onDepthLimit?: (dir: string) => void;
|
|
26
|
+
}
|
|
27
|
+
export declare function walkFiles(root: string, opts?: WalkOptions): Generator<string>;
|
|
10
28
|
export declare function readTextSafe(file: string, maxBytes?: number): string | undefined;
|
|
11
29
|
export declare function isBinaryPath(file: string): boolean;
|
|
12
30
|
export declare function extractVersion(s: string): number[] | undefined;
|
package/dist/util/misc.js
CHANGED
|
@@ -59,9 +59,15 @@ export function* walkFiles(root, opts = {}) {
|
|
|
59
59
|
for (const e of entries) {
|
|
60
60
|
const full = path.join(dir, e.name);
|
|
61
61
|
if (e.isDirectory()) {
|
|
62
|
-
if (
|
|
63
|
-
|
|
62
|
+
if (skip.has(e.name))
|
|
63
|
+
continue;
|
|
64
|
+
if (!opts.includeDotDirs && e.name.startsWith("."))
|
|
65
|
+
continue;
|
|
66
|
+
if (depth >= maxDepth) {
|
|
67
|
+
opts.onDepthLimit?.(full);
|
|
68
|
+
continue;
|
|
64
69
|
}
|
|
70
|
+
stack.push({ dir: full, depth: depth + 1 });
|
|
65
71
|
}
|
|
66
72
|
else if (e.isFile()) {
|
|
67
73
|
yield full;
|
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.
|
|
2
|
+
export declare const VERSION = "0.5.0";
|
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.
|
|
2
|
+
export const VERSION = "0.5.0";
|
package/docs/RULES.md
CHANGED
|
@@ -65,7 +65,7 @@ never reused, so a gap is permanent and is better stated than smoothed over.
|
|
|
65
65
|
| E128 | warn | total tool-definition tax over ~10k / ~25k tokens, estimated (needs `weigh` data); hard CI failure is left to the `--max-tokens`/`--diff` gate |
|
|
66
66
|
| E130 | info | instruction-injection patterns in a tool description or server instructions (shallow heuristic, never a security verdict; use a dedicated scanner for depth). **Reported but never scored** |
|
|
67
67
|
|
|
68
|
-
## Skills (S101-
|
|
68
|
+
## Skills (S101-S107)
|
|
69
69
|
|
|
70
70
|
| id | sev | what it catches | spec |
|
|
71
71
|
|---|---|---|---|
|
|
@@ -75,6 +75,7 @@ never reused, so a gap is permanent and is better stated than smoothed over.
|
|
|
75
75
|
| S104 | warn/info | metadata over ~100 tokens, body over ~5k tokens or 500 lines, or long body with no progressive disclosure | agentskills.io |
|
|
76
76
|
| S105 | info | instruction-injection patterns in the skill body or description (shallow heuristic, never a security verdict). **Reported but never scored** | internal |
|
|
77
77
|
| S106 | error/warn/info | referenced file missing, escaping the skill dir (`..`), or nested more than one level deep | agentskills.io |
|
|
78
|
+
| S107 | warn | a file one capitalisation away from a skill, such as `skill.md`. Set-level and never scored: the spec names `SKILL.md` exactly, so it is not a skill here on any platform, but a case-insensitive filesystem (the macOS and Windows default) may hand it to a host, which makes a repository work on a laptop and have no skill at all in Linux CI | internal |
|
|
78
79
|
|
|
79
80
|
## Findability (E141-E145)
|
|
80
81
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "efaimo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Audit what your agent loads: quality and context cost for MCP servers and Agent Skills",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -65,12 +65,14 @@
|
|
|
65
65
|
"prepublishOnly": "npm run typecheck && npm run build && npm run test"
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
|
-
"@modelcontextprotocol/
|
|
68
|
+
"@modelcontextprotocol/client": "2.0.0",
|
|
69
|
+
"@modelcontextprotocol/server": "2.0.0",
|
|
69
70
|
"commander": "^15.0.0",
|
|
70
71
|
"cross-spawn": "^7.0.6",
|
|
71
72
|
"gpt-tokenizer": "^3.4.0",
|
|
72
73
|
"picocolors": "^1.1.1",
|
|
73
|
-
"yaml": "^2.9.0"
|
|
74
|
+
"yaml": "^2.9.0",
|
|
75
|
+
"zod": "^4.5.4"
|
|
74
76
|
},
|
|
75
77
|
"devDependencies": {
|
|
76
78
|
"@types/cross-spawn": "^6.0.6",
|