@sabaiway/agent-workflow-kit 6.0.0 → 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.
Files changed (32) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +1 -0
  3. package/SKILL.md +5 -1
  4. package/bridges/antigravity-cli-bridge/bin/agy-review-await-guard.test.mjs +176 -0
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +61 -14
  6. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +606 -467
  7. package/bridges/antigravity-cli-bridge/references/review-prompt.md +42 -4
  8. package/bridges/codex-cli-bridge/SKILL.md +18 -5
  9. package/bridges/codex-cli-bridge/bin/codex-await-guard.test.mjs +161 -0
  10. package/bridges/codex-cli-bridge/bin/codex-exec.sh +22 -17
  11. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +356 -363
  12. package/bridges/codex-cli-bridge/bin/codex-review.sh +6 -6
  13. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +275 -286
  14. package/bridges/codex-cli-bridge/capability.json +1 -1
  15. package/bridges/codex-cli-bridge/references/driving-codex.md +4 -2
  16. package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +3 -2
  17. package/bridges/codex-cli-bridge/setup/README.md +3 -1
  18. package/capability.json +1 -1
  19. package/package.json +1 -1
  20. package/references/hooks/gate-approve.mjs +1 -1
  21. package/references/modes/mcp.md +37 -0
  22. package/references/modes/recommendations.md +1 -0
  23. package/references/modes/uninstall.md +2 -1
  24. package/tools/commands.mjs +7 -0
  25. package/tools/direct-run.mjs +3 -0
  26. package/tools/doc-parity.mjs +18 -2
  27. package/tools/mcp-registration.mjs +283 -0
  28. package/tools/mcp-server.mjs +314 -0
  29. package/tools/mcp-stdio.mjs +229 -0
  30. package/tools/mcp.mjs +299 -0
  31. package/tools/recommendations.mjs +90 -1
  32. package/tools/uninstall.mjs +356 -45
@@ -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
+ }
@@ -0,0 +1,229 @@
1
+ // mcp-stdio.mjs — JSON-RPC 2.0 line framing for the kit's stdio MCP server (the transport half).
2
+ //
3
+ // The MCP stdio transport is newline-delimited JSON-RPC over stdin/stdout: one message per line, no
4
+ // embedded newline, UTF-8, nothing on stdout that is not a message. This module owns exactly that and
5
+ // nothing about tools: a bounded line reader, the request/notification/response classification, the
6
+ // lifecycle gate (initialize first, ping any time) and a dispatcher that turns a parsed message into
7
+ // ONE response object or null. Streams are INJECTED so the whole contract is testable without a process.
8
+ //
9
+ // Bounds, stated: a line longer than `maxLineBytes` is answered with -32600 (id null) and the transport
10
+ // then CLOSES — a null-id error cannot be correlated with the request that caused it, so a client would
11
+ // otherwise wait on it forever, while a server that exits is one it can restart. (The line reader itself
12
+ // can resync at the next newline; the transport chooses not to continue.) Bytes are buffered and decoded
13
+ // only per complete line, so a UTF-8 code point split across two chunks is never replaced. Dependency-
14
+ // free, Node >= 22, no side effects on import; no CLI of its own (mcp-server.mjs is the entry point).
15
+
16
+ export const PROTOCOL_VERSION = '2025-06-18';
17
+ // Newest first. A requested version in this set is echoed; anything else is answered with the newest,
18
+ // as the lifecycle spec prescribes (the client then decides whether to continue).
19
+ export const SUPPORTED_PROTOCOL_VERSIONS = Object.freeze(['2025-06-18', '2025-03-26', '2024-11-05']);
20
+ export const MAX_LINE_BYTES = 4 * 1024 * 1024;
21
+ export const JSONRPC_ERRORS = Object.freeze({
22
+ PARSE_ERROR: -32700,
23
+ INVALID_REQUEST: -32600,
24
+ METHOD_NOT_FOUND: -32601,
25
+ INVALID_PARAMS: -32602,
26
+ INTERNAL_ERROR: -32603,
27
+ });
28
+
29
+ const JSONRPC_VERSION = '2.0';
30
+ const NEWLINE = 0x0a;
31
+ const CARRIAGE_RETURN = 0x0d;
32
+ const METHOD_INITIALIZE = 'initialize';
33
+ const METHOD_PING = 'ping';
34
+ const PRE_INITIALIZE_METHODS = Object.freeze([METHOD_INITIALIZE, METHOD_PING]);
35
+
36
+ export const rpcError = (code, message) => Object.assign(new Error(message), { rpcCode: code });
37
+
38
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
39
+ // MCP's RequestId is a string or an integer, never null; and a numeric id must survive parse →
40
+ // serialize unchanged, or the answer no longer correlates with the request: only a safe integer does
41
+ // (1.5, 1e400 and friends are refused as invalid requests). The server's OWN null id on an error
42
+ // for an unparseable line is a different thing: a response, not a request.
43
+ const isValidId = (id) => typeof id === 'string' || Number.isSafeInteger(id);
44
+ // A client RESPONSE carries an id and exactly one of result / error; it is the one shape the server
45
+ // never answers. Anything else without a method is an invalid request, answered — never swallowed.
46
+ const isClientResponse = (message) =>
47
+ 'id' in message && (Object.hasOwn(message, 'result') !== Object.hasOwn(message, 'error'));
48
+
49
+ export const negotiateProtocolVersion = (requested) =>
50
+ SUPPORTED_PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSION;
51
+
52
+ export const isNotification = (message) => typeof message.method === 'string' && !('id' in message);
53
+
54
+ // One line → { message } or { error: { code, message } } (the error is answered with id null).
55
+ export const parseLine = (line) => {
56
+ let parsed;
57
+ try {
58
+ parsed = JSON.parse(line);
59
+ } catch {
60
+ return { error: { code: JSONRPC_ERRORS.PARSE_ERROR, message: 'Parse error' } };
61
+ }
62
+ if (!isPlainObject(parsed) || parsed.jsonrpc !== JSONRPC_VERSION) {
63
+ return { error: { code: JSONRPC_ERRORS.INVALID_REQUEST, message: 'Invalid Request: not a JSON-RPC 2.0 object' } };
64
+ }
65
+ if ('id' in parsed && !isValidId(parsed.id)) {
66
+ return { error: { code: JSONRPC_ERRORS.INVALID_REQUEST, message: 'Invalid Request: id must be a string or an integer' } };
67
+ }
68
+ if ('method' in parsed && typeof parsed.method !== 'string') {
69
+ return { error: { code: JSONRPC_ERRORS.INVALID_REQUEST, message: 'Invalid Request: method must be a string' } };
70
+ }
71
+ return { message: parsed };
72
+ };
73
+
74
+ const errorResponse = (id, code, message) => ({ jsonrpc: JSONRPC_VERSION, id, error: { code, message } });
75
+ const resultResponse = (id, result) => ({ jsonrpc: JSONRPC_VERSION, id, result });
76
+
77
+ // A lossy decode would replace an invalid byte with U+FFFD and the request could then name a DIFFERENT,
78
+ // existing path — the substitution class both readers refuse; a line that is not UTF-8 is a parse error.
79
+ const strictUtf8 = new TextDecoder('utf-8', { fatal: true });
80
+
81
+ // Bytes in, complete lines out. The pending buffer is capped: past `maxLineBytes` without a newline the
82
+ // reader drops bytes until the next newline, reporting the overflow ONCE, then frames normally again.
83
+ export const createLineReader = ({ maxLineBytes = MAX_LINE_BYTES, onLine, onOverflow, onInvalidUtf8 = () => {} }) => {
84
+ let pending = [];
85
+ let pendingBytes = 0;
86
+ let discarding = false;
87
+
88
+ const emit = () => {
89
+ const buf = Buffer.concat(pending, pendingBytes);
90
+ pending = [];
91
+ pendingBytes = 0;
92
+ const end = buf.length > 0 && buf[buf.length - 1] === CARRIAGE_RETURN ? buf.length - 1 : buf.length;
93
+ let text;
94
+ try {
95
+ text = strictUtf8.decode(buf.subarray(0, end));
96
+ } catch {
97
+ onInvalidUtf8();
98
+ return;
99
+ }
100
+ onLine(text);
101
+ };
102
+
103
+ const feed = (chunk) => {
104
+ let from = 0;
105
+ for (;;) {
106
+ const at = chunk.indexOf(NEWLINE, from);
107
+ const piece = chunk.subarray(from, at === -1 ? chunk.length : at);
108
+ if (discarding) {
109
+ if (at === -1) return;
110
+ discarding = false;
111
+ } else if (pendingBytes + piece.length > maxLineBytes) {
112
+ pending = [];
113
+ pendingBytes = 0;
114
+ onOverflow();
115
+ if (at === -1) {
116
+ discarding = true;
117
+ return;
118
+ }
119
+ } else {
120
+ if (piece.length > 0) {
121
+ pending.push(piece);
122
+ pendingBytes += piece.length;
123
+ }
124
+ if (at === -1) return;
125
+ emit();
126
+ }
127
+ from = at + 1;
128
+ if (from >= chunk.length) return;
129
+ }
130
+ };
131
+
132
+ // A final line without a trailing newline is still a message; a discarded tail is not.
133
+ const end = () => {
134
+ if (!discarding && pendingBytes > 0) emit();
135
+ };
136
+
137
+ return { feed, end };
138
+ };
139
+
140
+ // handlers: { [method]: (params) => result } for everything beyond initialize/ping. A handler may throw
141
+ // rpcError(code, message) for a typed error; any other throw is an internal error WITH its message.
142
+ export const createDispatcher = ({ handlers = {}, serverInfo, capabilities = { tools: {} }, instructions }) => {
143
+ let initialized = false;
144
+
145
+ const handle = (message) => {
146
+ if (typeof message.method !== 'string') {
147
+ if (isClientResponse(message)) return null; // a response to something — never answered
148
+ return errorResponse(message.id ?? null, JSONRPC_ERRORS.INVALID_REQUEST, 'Invalid Request: neither a request, a notification nor a response');
149
+ }
150
+ if (isNotification(message)) return null; // initialized, cancelled, anything: silence
151
+ const { id, method } = message;
152
+ if ('params' in message && message.params !== undefined && !isPlainObject(message.params)) {
153
+ return errorResponse(id, JSONRPC_ERRORS.INVALID_PARAMS, `Invalid params: "${method}" params must be an object`);
154
+ }
155
+ if (!initialized && !PRE_INITIALIZE_METHODS.includes(method)) {
156
+ return errorResponse(id, JSONRPC_ERRORS.INVALID_REQUEST, `Invalid Request: "${method}" before initialize`);
157
+ }
158
+ if (method === METHOD_PING) return resultResponse(id, {});
159
+ if (method === METHOD_INITIALIZE) {
160
+ // The three required fields are checked BEFORE the state flips: a malformed initialize leaves the
161
+ // server un-initialized and is answered as invalid params, never served as a handshake.
162
+ const params = isPlainObject(message.params) ? message.params : null;
163
+ const client = params !== null && isPlainObject(params.clientInfo) ? params.clientInfo : null;
164
+ if (params === null || typeof params.protocolVersion !== 'string' || !isPlainObject(params.capabilities) || client === null || typeof client.name !== 'string' || typeof client.version !== 'string') {
165
+ return errorResponse(id, JSONRPC_ERRORS.INVALID_PARAMS, 'initialize: protocolVersion (string), capabilities (object) and clientInfo { name, version } (strings) are required');
166
+ }
167
+ initialized = true;
168
+ const result = { protocolVersion: negotiateProtocolVersion(params.protocolVersion), capabilities, serverInfo };
169
+ if (typeof instructions === 'string') result.instructions = instructions;
170
+ return resultResponse(id, result);
171
+ }
172
+ const handler = Object.hasOwn(handlers, method) ? handlers[method] : undefined;
173
+ if (typeof handler !== 'function') return errorResponse(id, JSONRPC_ERRORS.METHOD_NOT_FOUND, `Method not found: ${method}`);
174
+ try {
175
+ return resultResponse(id, handler(isPlainObject(message.params) ? message.params : {}));
176
+ } catch (err) {
177
+ if (typeof err?.rpcCode === 'number') return errorResponse(id, err.rpcCode, err.message);
178
+ return errorResponse(id, JSONRPC_ERRORS.INTERNAL_ERROR, `Internal error: ${err?.message ?? err}`);
179
+ }
180
+ };
181
+
182
+ return { handle };
183
+ };
184
+
185
+ // Serve until the input ends. `input` is an async iterable of Buffers (process.stdin, a PassThrough),
186
+ // `output` anything with write(string) — and, when it also has once(), its backpressure is honoured:
187
+ // a write that returns false stops the flush until 'drain', so a run of large answers never piles up
188
+ // unbounded in process memory. Every write is exactly one JSON document plus "\n" — JSON.stringify
189
+ // escapes every newline inside strings, so no message ever embeds one.
190
+ export const serveStdio = async ({ input, output, dispatcher, maxLineBytes = MAX_LINE_BYTES }) => {
191
+ // The reader only QUEUES framed lines (bounded by the chunk size); each line is dispatched and its
192
+ // answer written one at a time, with the drain wait BETWEEN lines — so a chunk holding many compact
193
+ // requests never has all their answers computed and held in memory at once.
194
+ const pending = [];
195
+ const reader = createLineReader({
196
+ maxLineBytes,
197
+ onOverflow: () => pending.push({ overflow: true }),
198
+ onInvalidUtf8: () => pending.push({ invalidUtf8: true }),
199
+ onLine: (line) => pending.push({ line }),
200
+ });
201
+ const answerFor = (entry) => {
202
+ if (entry.overflow) return errorResponse(null, JSONRPC_ERRORS.INVALID_REQUEST, `Invalid Request: line exceeds ${maxLineBytes} byte(s) — the transport is closing; restart the server`);
203
+ if (entry.invalidUtf8) return errorResponse(null, JSONRPC_ERRORS.PARSE_ERROR, 'Parse error: the line is not valid UTF-8');
204
+ if (entry.line.trim() === '') return null;
205
+ const parsed = parseLine(entry.line);
206
+ if (parsed.error) return errorResponse(null, parsed.error.code, parsed.error.message);
207
+ return dispatcher.handle(parsed.message);
208
+ };
209
+ // Returns true once the transport must close: a null-id overflow error cannot be correlated, so
210
+ // nothing after it is served — not even the rest of the same chunk.
211
+ const drainPending = async () => {
212
+ while (pending.length > 0) {
213
+ const entry = pending.shift();
214
+ const response = answerFor(entry);
215
+ if (response !== null) {
216
+ const accepted = output.write(`${JSON.stringify(response)}\n`);
217
+ if (accepted === false && typeof output.once === 'function') await new Promise((resolve) => output.once('drain', resolve));
218
+ }
219
+ if (entry.overflow) return true;
220
+ }
221
+ return false;
222
+ };
223
+ for await (const chunk of input) {
224
+ reader.feed(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
225
+ if (await drainPending()) return;
226
+ }
227
+ reader.end();
228
+ await drainPending();
229
+ };