@sabaiway/agent-workflow-kit 5.11.2 → 7.0.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 +117 -0
- package/README.md +3 -2
- package/SKILL.md +5 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review-await-guard.test.mjs +176 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +61 -14
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +606 -467
- package/bridges/antigravity-cli-bridge/references/review-prompt.md +42 -4
- package/bridges/codex-cli-bridge/SKILL.md +18 -5
- package/bridges/codex-cli-bridge/bin/codex-await-guard.test.mjs +161 -0
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +22 -17
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +356 -363
- package/bridges/codex-cli-bridge/bin/codex-review.sh +6 -6
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +275 -286
- package/bridges/codex-cli-bridge/capability.json +1 -1
- package/bridges/codex-cli-bridge/references/driving-codex.md +4 -2
- package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +3 -2
- package/bridges/codex-cli-bridge/setup/README.md +3 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/gate-approve.mjs +1 -1
- package/references/modes/grounding.md +1 -1
- package/references/modes/mcp.md +37 -0
- package/references/modes/procedures.md +3 -3
- package/references/modes/recommendations.md +1 -0
- package/references/modes/uninstall.md +2 -1
- package/references/templates/agent_rules.md +4 -5
- package/tools/commands.mjs +7 -0
- package/tools/direct-run.mjs +3 -0
- package/tools/doc-parity.mjs +18 -2
- package/tools/grounding.mjs +10 -20
- package/tools/inject-methodology.mjs +2 -0
- package/tools/mcp-registration.mjs +283 -0
- package/tools/mcp-server.mjs +314 -0
- package/tools/mcp-stdio.mjs +229 -0
- package/tools/mcp.mjs +299 -0
- package/tools/procedures.mjs +7 -8
- package/tools/recommendations.mjs +90 -1
- package/tools/uninstall.mjs +356 -45
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// mcp-registration.mjs — the READ-ONLY half of the `mcp` mode: is the kit's stdio MCP server
|
|
2
|
+
// registered in THIS project? It answers for two consumers that must never reach a writer — the
|
|
3
|
+
// `mcp-channel` advisor item and `uninstall`'s report — and the writer (mcp.mjs) composes its bodies
|
|
4
|
+
// from the same merges, so "what is there" and "what would be written" can never drift apart.
|
|
5
|
+
//
|
|
6
|
+
// Split out for the reason bridge-settings-read.mjs was (bridges 2.3.0, D6): a read-only consumer
|
|
7
|
+
// that imports the writer pulls in the atomic-write core, which read-graph-purity.test.mjs forbids.
|
|
8
|
+
// That is also why the two settings-path literals are NOT imported from velocity-profile.mjs (a
|
|
9
|
+
// write module) — the equality is pinned by a test instead.
|
|
10
|
+
//
|
|
11
|
+
// It reads through fs-read-nofollow.mjs rather than velocity's readSettingsFile because that reader
|
|
12
|
+
// follows a symlink: a symlinked `.mcp.json` whose TARGET is a perfect registration would report
|
|
13
|
+
// registered over a file the writer must refuse to touch. Every never-committable dirent class
|
|
14
|
+
// (the sandbox's own device masks, a FIFO, a socket) is a NAMED state, classified by lstat BEFORE
|
|
15
|
+
// any open. Nothing here throws: every failure is a state with its own reason.
|
|
16
|
+
//
|
|
17
|
+
// RESIDUAL, stated as a BOUNDARY. What this module closes is the STATIC case: a symlink, device,
|
|
18
|
+
// FIFO or socket ALREADY at a path is classified, named, and never read — a target decided FOREIGN
|
|
19
|
+
// is never opened. (A target decided REGULAR is of course opened; that is the read.)
|
|
20
|
+
//
|
|
21
|
+
// Against a path that CHANGES under it, this module promises nothing, and the two races are NOT the
|
|
22
|
+
// same shape:
|
|
23
|
+
// • the LEAF is protected by the shared reader as far as a path-based reader can go — it opens
|
|
24
|
+
// `O_NOFOLLOW`, fstats the DESCRIPTOR and reads through it, so a swapped SYMLINK cannot be
|
|
25
|
+
// followed. What it cannot catch is substitution by another REGULAR FILE, which needs the open
|
|
26
|
+
// bound to an earlier inode observation — inside fs-read-nofollow.mjs, which four consumers
|
|
27
|
+
// share, so that is the leaf's decision rather than this one's.
|
|
28
|
+
// • the CONTAINER cannot be closed that way at all: no property of the leaf's descriptor says
|
|
29
|
+
// anything about the directory the path was resolved through. That needs directory-relative
|
|
30
|
+
// opening (`openat`), which Node does not expose — so it is a platform limit, not a missing check.
|
|
31
|
+
// Classifying `.claude` before reading inside it closes the STATIC symlinked container — the real and
|
|
32
|
+
// reachable case, where a settings file outside the work tree became a verdict; a container swapped
|
|
33
|
+
// mid-flight is not closed, for the reason above. Earlier drafts of this comment enumerated windows
|
|
34
|
+
// and were corrected three rounds running, each time for being one window too generous. The bar this
|
|
35
|
+
// module meets: no STATIC foreign path is followed or read, and no target decided FOREIGN is opened.
|
|
36
|
+
//
|
|
37
|
+
// Dependency-free, Node >= 22. No writes, no CLI, no side effects on import.
|
|
38
|
+
|
|
39
|
+
import { join } from 'node:path';
|
|
40
|
+
import { fileURLToPath } from 'node:url';
|
|
41
|
+
import { describeNonRegular, lstatNoFollowRead, readRegularFileNoFollow } from './fs-read-nofollow.mjs';
|
|
42
|
+
import { refuseDirectRun } from './direct-run.mjs';
|
|
43
|
+
import { SERVER_NAME, TOOLS } from './mcp-server.mjs';
|
|
44
|
+
|
|
45
|
+
export { SERVER_NAME };
|
|
46
|
+
|
|
47
|
+
export const MCP_JSON_REL = '.mcp.json';
|
|
48
|
+
export const CLAUDE_DIR_REL = '.claude';
|
|
49
|
+
export const SETTINGS_REL = '.claude/settings.json';
|
|
50
|
+
export const SERVERS_KEY = 'mcpServers';
|
|
51
|
+
export const ENABLED_KEY = 'enabledMcpjsonServers';
|
|
52
|
+
// OUT OF SCOPE, deliberately and by name: `disabledMcpjsonServers`. A server listed there is rejected
|
|
53
|
+
// by the client in every mode, so a project can hold our entry, the enable and both rules and still
|
|
54
|
+
// have a dark channel — this mode does NOT detect that, and `registered` therefore means "the three
|
|
55
|
+
// things this mode writes are in place", never "the client will load it".
|
|
56
|
+
//
|
|
57
|
+
// It was implemented during review and then SUBTRACTED. The reason is worth keeping: honouring a veto
|
|
58
|
+
// means reading it from every scope the client merges, and each scope has its own masked, symlinked,
|
|
59
|
+
// malformed and unreadable states — in each of which a hidden deny still yields a confident answer.
|
|
60
|
+
// Three review rounds each closed one such hole and opened the next. A check that is wrong in states
|
|
61
|
+
// it cannot enumerate is worse than a stated limit, so this is the stated limit.
|
|
62
|
+
// The RUNNING kit copy's own server — the args entry a registration must carry to reach THIS kit.
|
|
63
|
+
export const DEFAULT_SERVER_PATH = fileURLToPath(new URL('./mcp-server.mjs', import.meta.url));
|
|
64
|
+
|
|
65
|
+
export const STATE = Object.freeze({
|
|
66
|
+
ABSENT: 'absent',
|
|
67
|
+
MASKED: 'masked',
|
|
68
|
+
FOREIGN: 'foreign',
|
|
69
|
+
UNREADABLE: 'unreadable',
|
|
70
|
+
MALFORMED: 'malformed',
|
|
71
|
+
PRESENT: 'present',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const LF = '\n';
|
|
75
|
+
const CRLF = '\r\n';
|
|
76
|
+
const JSON_INDENT = 2;
|
|
77
|
+
|
|
78
|
+
// The two allow rules a client needs, derived from the server's own name + tool list — never a
|
|
79
|
+
// re-typed pair (a renamed tool would otherwise ship a rule nothing grants).
|
|
80
|
+
export const allowRulesFor = (tools = TOOLS) => tools.map((tool) => `mcp__${SERVER_NAME}__${tool.name}`);
|
|
81
|
+
|
|
82
|
+
export const buildServerEntry = (serverPath) => ({ type: 'stdio', command: 'node', args: [serverPath] });
|
|
83
|
+
|
|
84
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
85
|
+
|
|
86
|
+
// Key-order-independent structural identity: an entry that differs only in key order is the SAME
|
|
87
|
+
// registration, while an extra key, a different command or a different arg is a real difference.
|
|
88
|
+
const stable = (value) => {
|
|
89
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'undefined';
|
|
90
|
+
if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
|
|
91
|
+
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stable(value[k])}`).join(',')}}`;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// The never-committable stat classes (core-evidence's own review-domain rule, re-decided here rather
|
|
95
|
+
// than imported: core-evidence reaches the atomic-write core and this module may not).
|
|
96
|
+
const isMaskStat = (st) => st.isCharacterDevice() || st.isBlockDevice() || st.isFIFO() || st.isSocket();
|
|
97
|
+
|
|
98
|
+
// lstat FIRST, then read: the classification decides whether an open may happen at all, so a FIFO is
|
|
99
|
+
// never opened and a symlink is never followed.
|
|
100
|
+
const readTarget = (abs, io) => {
|
|
101
|
+
let st;
|
|
102
|
+
try {
|
|
103
|
+
st = lstatNoFollowRead(abs, io.lstat);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
return { state: STATE.UNREADABLE, reason: (err && (err.code || err.message)) || 'lstat failed' };
|
|
106
|
+
}
|
|
107
|
+
if (st === null) return { state: STATE.ABSENT };
|
|
108
|
+
if (isMaskStat(st)) return { state: STATE.MASKED, className: describeNonRegular(st) };
|
|
109
|
+
if (!st.isFile()) return { state: STATE.FOREIGN, className: describeNonRegular(st) };
|
|
110
|
+
const r = readRegularFileNoFollow(abs, io);
|
|
111
|
+
if (r.outcome === 'absent') return { state: STATE.ABSENT };
|
|
112
|
+
if (r.outcome === 'foreign') return { state: STATE.FOREIGN, className: r.className };
|
|
113
|
+
if (r.outcome !== 'ok') return { state: STATE.UNREADABLE, reason: r.code };
|
|
114
|
+
return { state: STATE.PRESENT, text: r.content };
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const eolOf = (text) => (text.includes(CRLF) ? CRLF : LF);
|
|
118
|
+
|
|
119
|
+
// A file we cannot parse is MALFORMED with its own reason — never an empty object, which would let a
|
|
120
|
+
// writer clobber a file it never understood.
|
|
121
|
+
const parseJsonText = (text) => {
|
|
122
|
+
let data;
|
|
123
|
+
try {
|
|
124
|
+
data = JSON.parse(text);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
return { state: STATE.MALFORMED, reason: `not valid JSON (${(err && err.message) || 'parse failed'})` };
|
|
127
|
+
}
|
|
128
|
+
if (!isPlainObject(data)) return { state: STATE.MALFORMED, reason: 'the root is not a JSON object' };
|
|
129
|
+
return { state: STATE.PRESENT, data };
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// A MANAGED key of the wrong type is MALFORMED, not "empty". Reading it as an empty container and
|
|
133
|
+
// merging over it DESTROYS whatever it held — the merge-through-clobber `gate-hook.mjs`
|
|
134
|
+
// (assertHooksShape) already refuses for its own `hooks` key. Only the four keys this mode writes are
|
|
135
|
+
// judged; a foreign key of any shape is data, carried over untouched and never inspected.
|
|
136
|
+
const wrongType = (key, expected) => `carrying a "${key}" key that is not ${expected}`;
|
|
137
|
+
|
|
138
|
+
const NEUTRAL_ENTRY = { hasEntry: false, existing: null, matches: false, differs: false };
|
|
139
|
+
|
|
140
|
+
// The two decisions over TEXT. Exported because a consumer that already holds the bytes — the
|
|
141
|
+
// uninstaller, which reads every surface through its own injected fs — must answer "is this OUR
|
|
142
|
+
// registration?" by the SAME rule the reader uses, not by a second copy of it.
|
|
143
|
+
export const decideMcpJsonText = (text, entry) => {
|
|
144
|
+
const parsed = parseJsonText(text);
|
|
145
|
+
const eol = eolOf(text);
|
|
146
|
+
if (parsed.state !== STATE.PRESENT) return { ...parsed, eol, ...NEUTRAL_ENTRY };
|
|
147
|
+
const servers = parsed.data[SERVERS_KEY];
|
|
148
|
+
if (servers !== undefined && !isPlainObject(servers)) {
|
|
149
|
+
return { state: STATE.MALFORMED, eol, reason: wrongType(SERVERS_KEY, 'a JSON object'), ...NEUTRAL_ENTRY };
|
|
150
|
+
}
|
|
151
|
+
// PRESENCE is hasOwnProperty, never `value !== null`: a key that is THERE is a declaration this
|
|
152
|
+
// mode may refuse but must never replace — a literal `null` included.
|
|
153
|
+
const hasEntry = isPlainObject(servers) && Object.prototype.hasOwnProperty.call(servers, SERVER_NAME);
|
|
154
|
+
const existing = hasEntry ? servers[SERVER_NAME] : null;
|
|
155
|
+
const matches = hasEntry && stable(existing) === stable(entry);
|
|
156
|
+
return { ...parsed, eol, hasEntry, existing, matches, differs: hasEntry && !matches };
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const decideSettingsText = (text, allowRules) => {
|
|
160
|
+
const parsed = parseJsonText(text);
|
|
161
|
+
const eol = eolOf(text);
|
|
162
|
+
const neutral = { enabled: false, allowPresent: [], allowMissing: allowRules, complete: false };
|
|
163
|
+
if (parsed.state !== STATE.PRESENT) return { ...parsed, eol, ...neutral };
|
|
164
|
+
const malformed = (reason) => ({ state: STATE.MALFORMED, eol, reason, ...neutral });
|
|
165
|
+
const enabledList = parsed.data[ENABLED_KEY];
|
|
166
|
+
if (enabledList !== undefined && !Array.isArray(enabledList)) return malformed(wrongType(ENABLED_KEY, 'a JSON array'));
|
|
167
|
+
const permissions = parsed.data.permissions;
|
|
168
|
+
if (permissions !== undefined && !isPlainObject(permissions)) return malformed(wrongType('permissions', 'a JSON object'));
|
|
169
|
+
const allow = isPlainObject(permissions) ? permissions.allow : undefined;
|
|
170
|
+
if (allow !== undefined && !Array.isArray(allow)) return malformed(wrongType('permissions.allow', 'a JSON array'));
|
|
171
|
+
const rules = Array.isArray(allow) ? allow : [];
|
|
172
|
+
const enabled = Array.isArray(enabledList) && enabledList.includes(SERVER_NAME);
|
|
173
|
+
const allowMissing = allowRules.filter((rule) => !rules.includes(rule));
|
|
174
|
+
return {
|
|
175
|
+
...parsed,
|
|
176
|
+
eol,
|
|
177
|
+
enabled,
|
|
178
|
+
allowPresent: allowRules.filter((rule) => rules.includes(rule)),
|
|
179
|
+
allowMissing,
|
|
180
|
+
// "Everything this mode writes is in place" — see the scope note in the header for what that
|
|
181
|
+
// deliberately does NOT mean.
|
|
182
|
+
complete: enabled && allowMissing.length === 0,
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// readRegistration(root, io?) → the full registration picture of ONE project. `io.serverPath`
|
|
187
|
+
// overrides the running kit's server path (tests); every fs primitive in `io` is the fs-read-nofollow
|
|
188
|
+
// injection contract. NEVER throws.
|
|
189
|
+
export const readRegistration = (root, io = {}) => {
|
|
190
|
+
const serverPath = io.serverPath ?? DEFAULT_SERVER_PATH;
|
|
191
|
+
const entry = buildServerEntry(serverPath);
|
|
192
|
+
const allowRules = allowRulesFor();
|
|
193
|
+
|
|
194
|
+
const mcpAbs = join(root, MCP_JSON_REL);
|
|
195
|
+
const mcpRead = readTarget(mcpAbs, io);
|
|
196
|
+
const mcpJson = { rel: MCP_JSON_REL, abs: mcpAbs, eol: LF, ...NEUTRAL_ENTRY, ...mcpRead,
|
|
197
|
+
...(mcpRead.state === STATE.PRESENT ? decideMcpJsonText(mcpRead.text, entry) : {}) };
|
|
198
|
+
|
|
199
|
+
// The CONTAINER is classified BEFORE the file inside it is read, and the order is load-bearing:
|
|
200
|
+
// path resolution follows an INTERMEDIATE symlink (O_NOFOLLOW guards the FINAL component only), so
|
|
201
|
+
// reading first would pull a settings file from outside the work tree into memory — and into a
|
|
202
|
+
// verdict, and into a rendered merge body — before anything got the chance to refuse it.
|
|
203
|
+
const claudeDirAbs = join(root, CLAUDE_DIR_REL);
|
|
204
|
+
const claudeDir = { rel: CLAUDE_DIR_REL, abs: claudeDirAbs, ...classifyDir(claudeDirAbs, io) };
|
|
205
|
+
const containerUsable = claudeDir.state === STATE.PRESENT || claudeDir.state === STATE.ABSENT;
|
|
206
|
+
|
|
207
|
+
// settings.local.json is NOT read at all: this mode never writes it, so counting a rule that lives
|
|
208
|
+
// only there would report a registration the writer cannot maintain. (Reading it for the deny veto
|
|
209
|
+
// alone was tried and subtracted — see the scope note in the header.)
|
|
210
|
+
const settingsAbs = join(root, SETTINGS_REL);
|
|
211
|
+
const settingsRead = containerUsable
|
|
212
|
+
? readTarget(settingsAbs, io)
|
|
213
|
+
: { state: STATE.UNREADABLE, reason: `${CLAUDE_DIR_REL} is a ${claudeDir.className ?? claudeDir.reason} — refusing to read through it` };
|
|
214
|
+
const neutralAllow = { enabled: false, allowPresent: [], allowMissing: allowRules, complete: false };
|
|
215
|
+
const settings = { rel: SETTINGS_REL, abs: settingsAbs, eol: LF, ...neutralAllow, ...settingsRead,
|
|
216
|
+
...(settingsRead.state === STATE.PRESENT ? decideSettingsText(settingsRead.text, allowRules) : {}) };
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
root,
|
|
220
|
+
serverPath,
|
|
221
|
+
entry,
|
|
222
|
+
allowRules,
|
|
223
|
+
mcpJson,
|
|
224
|
+
settings,
|
|
225
|
+
claudeDir,
|
|
226
|
+
registered: mcpJson.matches && settings.complete,
|
|
227
|
+
};
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// The `.claude/` container: an ABSENT dir is a named state the preflight reports and only `--apply`
|
|
231
|
+
// resolves (creating it here would make a read-only preflight write).
|
|
232
|
+
const classifyDir = (abs, io) => {
|
|
233
|
+
let st;
|
|
234
|
+
try {
|
|
235
|
+
st = lstatNoFollowRead(abs, io.lstat);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
return { state: STATE.UNREADABLE, reason: (err && (err.code || err.message)) || 'lstat failed' };
|
|
238
|
+
}
|
|
239
|
+
if (st === null) return { state: STATE.ABSENT };
|
|
240
|
+
if (st.isDirectory()) return { state: STATE.PRESENT };
|
|
241
|
+
return { state: STATE.FOREIGN, className: describeNonRegular(st) };
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// ── the merges (ONE definition, shared by the writer's bodies and the paste-ready fragments) ──
|
|
245
|
+
// Merge-don't-clobber in both: every foreign server, key and allow rule is carried over untouched,
|
|
246
|
+
// and our own entry/rules are added idempotently.
|
|
247
|
+
|
|
248
|
+
export const mergeMcpJson = (registration) => {
|
|
249
|
+
const base = isPlainObject(registration.mcpJson.data) ? registration.mcpJson.data : {};
|
|
250
|
+
const servers = isPlainObject(base[SERVERS_KEY]) ? base[SERVERS_KEY] : {};
|
|
251
|
+
return { ...base, [SERVERS_KEY]: { ...servers, [SERVER_NAME]: registration.entry } };
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
export const mergeSettings = (registration) => {
|
|
255
|
+
const base = isPlainObject(registration.settings.data) ? registration.settings.data : {};
|
|
256
|
+
const enabled = Array.isArray(base[ENABLED_KEY]) ? base[ENABLED_KEY] : [];
|
|
257
|
+
const permissions = isPlainObject(base.permissions) ? base.permissions : {};
|
|
258
|
+
const allow = Array.isArray(permissions.allow) ? permissions.allow : [];
|
|
259
|
+
return {
|
|
260
|
+
...base,
|
|
261
|
+
[ENABLED_KEY]: enabled.includes(SERVER_NAME) ? enabled : [...enabled, SERVER_NAME],
|
|
262
|
+
permissions: { ...permissions, allow: [...allow, ...registration.allowRules.filter((rule) => !allow.includes(rule))] },
|
|
263
|
+
};
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
export const formatJson = (data, eol) => `${JSON.stringify(data, null, JSON_INDENT).replaceAll(LF, eol)}${eol}`;
|
|
267
|
+
|
|
268
|
+
// The two hand-apply bodies — the whole output of the MASKED arm.
|
|
269
|
+
//
|
|
270
|
+
// They are deliberately DIFFERENT shapes, because what the kit knows about each file differs. The
|
|
271
|
+
// settings file was OBSERVABLE — and read where present — so its body is a real merge: it already
|
|
272
|
+
// carries every foreign key it had. The masked `.mcp.json` was not observable at all, so a
|
|
273
|
+
// whole-file body would name only our server and, pasted as
|
|
274
|
+
// instructed, would delete every foreign server the mask hid. `mcpEntry` is therefore the ENTRY
|
|
275
|
+
// ALONE, to be merged under `mcpServers` by a human who can see what is actually in that file.
|
|
276
|
+
export const renderFragments = (registration) => ({
|
|
277
|
+
mcpEntry: formatJson(registration.entry, registration.mcpJson.eol),
|
|
278
|
+
settings: formatJson(mergeSettings(registration), registration.settings.eol),
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// A LIBRARY module the mode doc names by path — so someone can try to run it. A no-op on import;
|
|
282
|
+
// on a direct run it points at the command that acts on what this module only reports.
|
|
283
|
+
refuseDirectRun(import.meta.url);
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// mcp-server.mjs — the kit's stdio MCP server: the two promptless readers as TYPED tools.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS. Every lane an agent used for a path question or a literal search ended in a STRING
|
|
5
|
+
// handed to a shell, and a string always admits a pipe, a redirect, a quote, an `||`. Here the same two
|
|
6
|
+
// readers (path-inventory.mjs, repo-search.mjs) are reached through named JSON fields: validated against
|
|
7
|
+
// a CLOSED schema, turned into an in-process argv LIST, handed to each reader's exported `main(argv,
|
|
8
|
+
// {cwd})`. No shell, no subprocess, no string — a decoration has no slot; `>`, a backtick, `$(` are bytes.
|
|
9
|
+
// Root = `--root` > env CLAUDE_PROJECT_DIR (Claude Code sets it for a stdio server) > cwd; containment
|
|
10
|
+
// stays the readers' own real-path rule. A client's child, outside any Bash sandbox, read-only,
|
|
11
|
+
// root-contained. Dependency-free, Node >= 22, no side effects on import.
|
|
12
|
+
|
|
13
|
+
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
14
|
+
import { resolve } from 'node:path';
|
|
15
|
+
import { PassThrough } from 'node:stream';
|
|
16
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
17
|
+
import { JSONRPC_ERRORS, createDispatcher, rpcError, serveStdio } from './mcp-stdio.mjs';
|
|
18
|
+
import {
|
|
19
|
+
main as inventoryMain,
|
|
20
|
+
HARD_MAX_CONTENT_BYTES,
|
|
21
|
+
HARD_MAX_ENTRIES,
|
|
22
|
+
HARD_MAX_TOTAL_BYTES as INVENTORY_HARD_MAX_TOTAL_BYTES,
|
|
23
|
+
HARD_MAX_TOTAL_ENTRIES,
|
|
24
|
+
} from './path-inventory.mjs';
|
|
25
|
+
import {
|
|
26
|
+
main as searchMain,
|
|
27
|
+
HARD_MAX_TARGETS,
|
|
28
|
+
HARD_MAX_RESULTS,
|
|
29
|
+
HARD_MAX_FILE_BYTES,
|
|
30
|
+
HARD_MAX_TOTAL_BYTES as SEARCH_HARD_MAX_TOTAL_BYTES,
|
|
31
|
+
} from './repo-search.mjs';
|
|
32
|
+
|
|
33
|
+
export const SERVER_NAME = 'agent-workflow';
|
|
34
|
+
const EXIT_OK = 0;
|
|
35
|
+
const EXIT_FAILED = 1;
|
|
36
|
+
const EXIT_USAGE = 2;
|
|
37
|
+
const ROOT_ENV = 'CLAUDE_PROJECT_DIR';
|
|
38
|
+
const READ_ONLY = Object.freeze({ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false });
|
|
39
|
+
const INSTRUCTIONS =
|
|
40
|
+
'Read-only tools over the project root. Prefer them to a shell for a path question (exists / type / size / lines / listing / a small file) and for a literal search: a pattern or path is a JSON field, never a command string.';
|
|
41
|
+
|
|
42
|
+
const pathList = (description) => ({
|
|
43
|
+
type: 'array',
|
|
44
|
+
items: { type: 'string', minLength: 1 },
|
|
45
|
+
minItems: 1,
|
|
46
|
+
maxItems: HARD_MAX_TARGETS,
|
|
47
|
+
description,
|
|
48
|
+
});
|
|
49
|
+
const count = (maximum, description) => ({ type: 'integer', minimum: 0, maximum, description });
|
|
50
|
+
|
|
51
|
+
// The public definitions — exactly what tools/list returns. The schema a client sees is the schema
|
|
52
|
+
// validateArgs enforces: one object, no second copy to drift.
|
|
53
|
+
export const TOOLS = Object.freeze([
|
|
54
|
+
Object.freeze({
|
|
55
|
+
name: 'path_inventory',
|
|
56
|
+
title: 'Path inventory',
|
|
57
|
+
description:
|
|
58
|
+
'Facts about named paths inside the project root, in ONE call: exists, type, bytes, line count (wc -l compatible), a directory listing (one level), and with contents=true the text of a small file. A missing path is a RESULT (absent), never an error. Symlinks are reported by type and never followed; binaries are never decoded.',
|
|
59
|
+
inputSchema: {
|
|
60
|
+
type: 'object',
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
required: ['paths'],
|
|
63
|
+
properties: {
|
|
64
|
+
paths: pathList('Project-relative paths, any number; a trailing "/" asserts a directory.'),
|
|
65
|
+
contents: { type: 'boolean', description: 'Also return the text of each regular text file.' },
|
|
66
|
+
maxContentBytes: count(HARD_MAX_CONTENT_BYTES, 'Per-file ceiling for the line count and contents.'),
|
|
67
|
+
maxEntries: count(HARD_MAX_ENTRIES, 'Per-directory listing ceiling.'),
|
|
68
|
+
maxTotalBytes: count(INVENTORY_HARD_MAX_TOTAL_BYTES, 'Whole-call byte ceiling.'),
|
|
69
|
+
maxTotalEntries: count(HARD_MAX_TOTAL_ENTRIES, 'Whole-call listed-entries ceiling.'),
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
annotations: READ_ONLY,
|
|
73
|
+
}),
|
|
74
|
+
Object.freeze({
|
|
75
|
+
name: 'repo_search',
|
|
76
|
+
title: 'Repository search (literal)',
|
|
77
|
+
description:
|
|
78
|
+
'LITERAL search (no regex) for a pattern across the project root or the named paths: every hit as file:line with a bounded snippet. The pattern is a plain JSON string, so shell-significant bytes need no quoting. A fired bound is reported as INCOMPLETE with its name, never as an empty result.',
|
|
79
|
+
inputSchema: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
additionalProperties: false,
|
|
82
|
+
required: ['pattern'],
|
|
83
|
+
properties: {
|
|
84
|
+
pattern: { type: 'string', minLength: 1, description: 'The literal bytes to find; multiline allowed.' },
|
|
85
|
+
paths: pathList('Project-relative search targets (default: the whole root).'),
|
|
86
|
+
max: count(HARD_MAX_RESULTS, 'Result ceiling.'),
|
|
87
|
+
maxBytes: count(HARD_MAX_FILE_BYTES, 'Per-file byte ceiling.'),
|
|
88
|
+
maxTotalBytes: count(SEARCH_HARD_MAX_TOTAL_BYTES, 'Whole-call byte ceiling.'),
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
annotations: READ_ONLY,
|
|
92
|
+
}),
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
const numericFlags = (value, pairs) => pairs.flatMap(([key, flag]) => (value[key] === undefined ? [] : [flag, String(value[key])]));
|
|
96
|
+
const pathFlags = (paths = []) => paths.flatMap((p) => ['--path', p]);
|
|
97
|
+
|
|
98
|
+
// Field → argv, deterministic and ONE flag per field. Kept beside the public definitions by NAME.
|
|
99
|
+
const RUNTIME = Object.freeze({
|
|
100
|
+
path_inventory: Object.freeze({
|
|
101
|
+
main: inventoryMain,
|
|
102
|
+
argv: (v) => [
|
|
103
|
+
...pathFlags(v.paths),
|
|
104
|
+
...(v.contents === true ? ['--contents'] : []),
|
|
105
|
+
...numericFlags(v, [['maxContentBytes', '--max-content-bytes'], ['maxEntries', '--max-entries'], ['maxTotalBytes', '--max-total-bytes'], ['maxTotalEntries', '--max-total-entries']]),
|
|
106
|
+
],
|
|
107
|
+
}),
|
|
108
|
+
repo_search: Object.freeze({
|
|
109
|
+
main: searchMain,
|
|
110
|
+
argv: (v) => ['--pattern', v.pattern, ...pathFlags(v.paths), ...numericFlags(v, [['max', '--max'], ['maxBytes', '--max-bytes'], ['maxTotalBytes', '--max-total-bytes']])],
|
|
111
|
+
}),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const toolByName = (name) => TOOLS.find((t) => t.name === name);
|
|
115
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
116
|
+
|
|
117
|
+
// A walker over the schema subset the two tools use — the public schema IS the validator's input. A
|
|
118
|
+
// schema type outside that subset is a fault, never a silent pass; exported so that arm has a test.
|
|
119
|
+
export const checkAgainst = (schema, value, at) => {
|
|
120
|
+
if (schema.type === 'object') {
|
|
121
|
+
if (!isPlainObject(value)) return `${at} must be an object`;
|
|
122
|
+
for (const key of Object.keys(value)) if (!Object.hasOwn(schema.properties, key)) return `${at}: unknown key "${key}"`;
|
|
123
|
+
for (const key of schema.required ?? []) if (!Object.hasOwn(value, key)) return `${at}: "${key}" is required`;
|
|
124
|
+
for (const [key, sub] of Object.entries(schema.properties)) {
|
|
125
|
+
if (!Object.hasOwn(value, key)) continue;
|
|
126
|
+
const fault = checkAgainst(sub, value[key], `${at}.${key}`);
|
|
127
|
+
if (fault !== null) return fault;
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
if (schema.type === 'string') {
|
|
132
|
+
if (typeof value !== 'string') return `${at} must be a string`;
|
|
133
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) return `${at} must not be empty`;
|
|
134
|
+
// A lone surrogate becomes U+FFFD on the way to the filesystem, so the reader could answer about
|
|
135
|
+
// a DIFFERENT, existing path — the substitution class the readers refuse; refused here, before argv.
|
|
136
|
+
if (!value.isWellFormed()) return `${at} must be well-formed Unicode (no lone surrogate)`;
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
if (schema.type === 'boolean') return typeof value === 'boolean' ? null : `${at} must be a boolean`;
|
|
140
|
+
if (schema.type === 'integer') {
|
|
141
|
+
if (!Number.isInteger(value)) return `${at} must be an integer`;
|
|
142
|
+
if (schema.minimum !== undefined && value < schema.minimum) return `${at} must be >= ${schema.minimum}`;
|
|
143
|
+
if (schema.maximum !== undefined && value > schema.maximum) return `${at} must be <= ${schema.maximum}`;
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
if (schema.type === 'array') {
|
|
147
|
+
if (!Array.isArray(value)) return `${at} must be an array`;
|
|
148
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) return `${at} must not be empty`;
|
|
149
|
+
if (schema.maxItems !== undefined && value.length > schema.maxItems) return `${at} holds more than ${schema.maxItems} item(s)`;
|
|
150
|
+
for (const [i, item] of value.entries()) {
|
|
151
|
+
const fault = checkAgainst(schema.items, item, `${at}[${i}]`);
|
|
152
|
+
if (fault !== null) return fault;
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
return `${at}: unsupported schema type ${schema.type}`;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const validateArgs = (name, args) => {
|
|
160
|
+
const tool = toolByName(name);
|
|
161
|
+
if (tool === undefined) return { ok: false, message: `Unknown tool: ${name}` };
|
|
162
|
+
const fault = checkAgainst(tool.inputSchema, args, 'arguments');
|
|
163
|
+
return fault === null ? { ok: true, value: args } : { ok: false, message: fault };
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export const toolArgv = (name, args) => {
|
|
167
|
+
if (!Object.hasOwn(RUNTIME, name)) throw rpcError(JSONRPC_ERRORS.INVALID_PARAMS, `Unknown tool: ${name}`);
|
|
168
|
+
return RUNTIME[name].argv(args);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Reader outcome → tool result. 0 and 3 (INCOMPLETE, the reader names the bound in its own stdout)
|
|
172
|
+
// are answers; 1 (I/O or containment refusal) and 2 (usage) are errors carrying the reader's stderr.
|
|
173
|
+
export const toToolResult = (r) => {
|
|
174
|
+
const isError = !(r.code === 0 || r.code === 3);
|
|
175
|
+
const text = isError ? (r.stderr || r.stdout) : r.stdout;
|
|
176
|
+
return { content: [{ type: 'text', text }], isError };
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export const callTool = (name, args, root) => {
|
|
180
|
+
if (!Object.hasOwn(RUNTIME, name)) throw rpcError(JSONRPC_ERRORS.INVALID_PARAMS, `Unknown tool: ${name}`);
|
|
181
|
+
const verdict = validateArgs(name, args);
|
|
182
|
+
if (!verdict.ok) throw rpcError(JSONRPC_ERRORS.INVALID_PARAMS, `${name}: ${verdict.message}`);
|
|
183
|
+
return toToolResult(RUNTIME[name].main(toolArgv(name, verdict.value), { cwd: root }));
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const usage = (message) => Object.assign(new Error(message), { exitCode: EXIT_USAGE });
|
|
187
|
+
|
|
188
|
+
export const parseArgv = (argv) => {
|
|
189
|
+
const opts = { root: null, selfCheck: false, help: false };
|
|
190
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
191
|
+
const arg = argv[i];
|
|
192
|
+
if (arg === '--help' || arg === '-h') opts.help = true;
|
|
193
|
+
else if (arg === '--self-check') opts.selfCheck = true;
|
|
194
|
+
else if (arg === '--root') {
|
|
195
|
+
i += 1;
|
|
196
|
+
// An EMPTY value is refused: `'' ?? env` keeps the empty string, and resolve(cwd, '') is the cwd —
|
|
197
|
+
// a silent override of a correct CLAUDE_PROJECT_DIR by whatever directory the client started in.
|
|
198
|
+
if (argv[i] === undefined || argv[i] === '') throw usage('--root requires a non-empty value');
|
|
199
|
+
opts.root = argv[i];
|
|
200
|
+
} else throw usage(`unknown argument: ${arg} (see --help)`);
|
|
201
|
+
}
|
|
202
|
+
return opts;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
export const resolveRoot = ({ argv = [], env = {}, cwd }) => {
|
|
206
|
+
const opts = parseArgv(argv);
|
|
207
|
+
const fromEnv = typeof env[ROOT_ENV] === 'string' && env[ROOT_ENV] !== '' ? env[ROOT_ENV] : null;
|
|
208
|
+
// The same rule as a tool argument: a lone surrogate would reach the filesystem as U+FFFD and pick
|
|
209
|
+
// a DIFFERENT existing directory as the root. EVERY string resolve() will see is checked — the flag,
|
|
210
|
+
// the env value and the cwd a relative candidate resolves against — not only the one selected.
|
|
211
|
+
for (const [source, value] of [['--root', opts.root], [ROOT_ENV, fromEnv], ['cwd', cwd]]) {
|
|
212
|
+
if (value === null) continue;
|
|
213
|
+
if (typeof value !== 'string' || !value.isWellFormed()) throw usage(`${source} must be a well-formed Unicode string (no lone surrogate)`);
|
|
214
|
+
}
|
|
215
|
+
const candidate = opts.root ?? fromEnv ?? cwd;
|
|
216
|
+
let real;
|
|
217
|
+
try {
|
|
218
|
+
real = realpathSync(resolve(cwd, candidate));
|
|
219
|
+
} catch (err) {
|
|
220
|
+
throw usage(`root does not exist: ${candidate} (${err?.code ?? err?.message ?? err})`);
|
|
221
|
+
}
|
|
222
|
+
if (!statSync(real).isDirectory()) throw usage(`root is not a directory: ${candidate}`);
|
|
223
|
+
return real;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// The kit's package version, informational: an unreadable or versionless package.json degrades to 0.0.0.
|
|
227
|
+
export const readServerVersion = (readFile = readFileSync) => {
|
|
228
|
+
try {
|
|
229
|
+
return JSON.parse(readFile(new URL('../package.json', import.meta.url), 'utf8')).version ?? '0.0.0';
|
|
230
|
+
} catch {
|
|
231
|
+
return '0.0.0';
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export const createServer = ({ root }) =>
|
|
236
|
+
createDispatcher({
|
|
237
|
+
serverInfo: { name: SERVER_NAME, version: readServerVersion() },
|
|
238
|
+
capabilities: { tools: {} },
|
|
239
|
+
instructions: INSTRUCTIONS,
|
|
240
|
+
handlers: {
|
|
241
|
+
'tools/list': () => ({ tools: TOOLS }),
|
|
242
|
+
'tools/call': (params) => {
|
|
243
|
+
if (typeof params.name !== 'string') throw rpcError(JSONRPC_ERRORS.INVALID_PARAMS, 'tools/call: "name" must be a string');
|
|
244
|
+
return callTool(params.name, params.arguments ?? {}, root);
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// In-process round trip through the SAME transport and dispatcher a client drives: the installed bytes
|
|
250
|
+
// load, the handshake answers, both tools answer a call rooted here. No process is spawned.
|
|
251
|
+
export const selfCheck = async ({ root }) => {
|
|
252
|
+
const input = new PassThrough();
|
|
253
|
+
const lines = [];
|
|
254
|
+
const done = serveStdio({ input, output: { write: (t) => lines.push(t) }, dispatcher: createServer({ root }) });
|
|
255
|
+
const requests = [
|
|
256
|
+
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'self-check', version: '0' } } },
|
|
257
|
+
{ jsonrpc: '2.0', method: 'notifications/initialized' },
|
|
258
|
+
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
|
|
259
|
+
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'path_inventory', arguments: { paths: ['.'] } } },
|
|
260
|
+
{ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'repo_search', arguments: { pattern: '2>/dev/null', paths: ['.'], max: 1 } } },
|
|
261
|
+
];
|
|
262
|
+
input.end(requests.map((r) => `${JSON.stringify(r)}\n`).join(''));
|
|
263
|
+
await done;
|
|
264
|
+
const answers = lines.map((l) => JSON.parse(l));
|
|
265
|
+
const byId = (id) => answers.find((a) => a.id === id);
|
|
266
|
+
const checks = [
|
|
267
|
+
['initialize answered', byId(1)?.result?.protocolVersion !== undefined],
|
|
268
|
+
['tools/list names both tools', JSON.stringify(byId(2)?.result?.tools?.map((t) => t.name)) === JSON.stringify(TOOLS.map((t) => t.name))],
|
|
269
|
+
['path_inventory answers', byId(3)?.result?.isError === false],
|
|
270
|
+
['repo_search answers a shell-significant pattern', byId(4)?.result?.isError === false],
|
|
271
|
+
];
|
|
272
|
+
const ok = checks.every(([, passed]) => passed);
|
|
273
|
+
const report = [...checks.map(([label, passed]) => ` ${passed ? 'ok ' : 'FAIL'} ${label}`), `self-check: ${ok ? 'OK' : 'FAILED'} (root ${root})`];
|
|
274
|
+
return { ok, report };
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const HELP = `mcp-server — the kit's stdio MCP server (server name "${SERVER_NAME}").
|
|
278
|
+
|
|
279
|
+
Tools: ${TOOLS.map((t) => t.name).join(', ')} — the kit's read-only path inventory and literal search,
|
|
280
|
+
reached through typed JSON fields instead of a shell string.
|
|
281
|
+
Usage:
|
|
282
|
+
node mcp-server.mjs [--root <dir>] serve JSON-RPC over stdin/stdout until stdin closes
|
|
283
|
+
node mcp-server.mjs --self-check in-process handshake + one call per tool, exit 0 on success
|
|
284
|
+
Root = --root > env ${ROOT_ENV} > cwd; every path is contained to it on the REAL path.
|
|
285
|
+
Exit codes: 0 served / self-check passed · 1 self-check failed · 2 usage.`;
|
|
286
|
+
|
|
287
|
+
export const main = async (argv, deps = {}) => {
|
|
288
|
+
const out = deps.stdout ?? process.stdout;
|
|
289
|
+
const err = deps.stderr ?? process.stderr;
|
|
290
|
+
try {
|
|
291
|
+
const opts = parseArgv(argv);
|
|
292
|
+
if (opts.help) {
|
|
293
|
+
out.write(`${HELP}\n`);
|
|
294
|
+
return EXIT_OK;
|
|
295
|
+
}
|
|
296
|
+
const root = resolveRoot({ argv, env: deps.env ?? process.env, cwd: deps.cwd ?? process.cwd() });
|
|
297
|
+
if (opts.selfCheck) {
|
|
298
|
+
const result = await selfCheck({ root });
|
|
299
|
+
out.write(`${result.report.join('\n')}\n`);
|
|
300
|
+
return result.ok ? EXIT_OK : EXIT_FAILED;
|
|
301
|
+
}
|
|
302
|
+
await serveStdio({ input: deps.stdin ?? process.stdin, output: out, dispatcher: createServer({ root }) });
|
|
303
|
+
return EXIT_OK;
|
|
304
|
+
} catch (e) {
|
|
305
|
+
err.write(`mcp-server: ${e?.message ?? e}\n`);
|
|
306
|
+
return e?.exitCode ?? EXIT_FAILED;
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
if (isDirectRun(import.meta.url)) {
|
|
311
|
+
main(process.argv.slice(2)).then((code) => {
|
|
312
|
+
process.exitCode = code;
|
|
313
|
+
});
|
|
314
|
+
}
|