flecto 3.1.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +262 -1
- package/README.md +46 -3
- package/drift.js +226 -0
- package/index.js +527 -31
- package/package.json +24 -14
- package/src/alerter.js +272 -24
- package/src/config.js +208 -2
- package/src/drift-sources.js +444 -0
- package/src/explain.js +706 -0
- package/src/lsp-analysis.js +397 -0
- package/src/lsp-worker.js +13 -0
- package/src/lsp.js +407 -0
- package/src/mcp.js +487 -0
- package/src/parser.js +24 -14
- package/src/policy.js +71 -47
- package/src/positions.js +1063 -0
- package/src/pr-comment.js +33 -1
- package/src/regex-engine.js +138 -0
- package/src/renderer.js +24 -0
- package/src/snapshot-store.js +6 -2
package/src/mcp.js
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
2
|
+
import { existsSync, realpathSync } from 'fs';
|
|
3
|
+
import { isAbsolute, resolve, sep } from 'path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `flecto mcp` — a read-only Model Context Protocol server over stdio (#140).
|
|
7
|
+
*
|
|
8
|
+
* The argument for it is the one the CLI cannot make on its own: an agent asked
|
|
9
|
+
* to debug a config incident reads the whole file into context to learn that
|
|
10
|
+
* `pool_size` doubled. Flecto already computes that small answer; this hands it
|
|
11
|
+
* to the agent as a structured tool result instead of a screen-scrape.
|
|
12
|
+
*
|
|
13
|
+
* ## Why it is built as a translator over the CLI
|
|
14
|
+
*
|
|
15
|
+
* Every tool here runs the *same* read-only `flecto ci --format json` path a
|
|
16
|
+
* pull request triggers, as a subprocess, and returns its JSON envelope. That is
|
|
17
|
+
* deliberate, and it is the whole security posture:
|
|
18
|
+
*
|
|
19
|
+
* - **Read-only by construction.** A tool can only reach what `ci` reaches. It
|
|
20
|
+
* never passes `--command`, `--pr-comment-post`, `--baseline`,
|
|
21
|
+
* `--update-baseline`, `--output`, or `--plugins`, so there is no argument by
|
|
22
|
+
* which an agent-supplied value becomes a write or a shell command. That is
|
|
23
|
+
* GHSA-wq8m-fc3q-8m5x's lesson generalized: a tool an agent can invoke must
|
|
24
|
+
* not be able to execute a shell command. Nor can a value smuggle one of those
|
|
25
|
+
* options in: files follow a `--`, values ride as `--name=value`, and a file
|
|
26
|
+
* argument starting with `-` is refused before anything spawns.
|
|
27
|
+
* - **Plugins stay off**, regardless of `FLECTO_ALLOW_RC_PLUGINS` in the
|
|
28
|
+
* environment — the runner strips it from the child, because model-supplied
|
|
29
|
+
* arguments are untrusted input by definition and an rc-declared plugin is
|
|
30
|
+
* code.
|
|
31
|
+
* - **Path containment** is enforced twice: `assertSafeTargetArg` refuses a
|
|
32
|
+
* traversal in a tool argument before anything spawns, and the CLI then
|
|
33
|
+
* applies its own symlink-escape check on every resolved target
|
|
34
|
+
* (`FLECTO_ALLOW_SYMLINK_TARGETS` is stripped from the child, so it cannot be
|
|
35
|
+
* switched off).
|
|
36
|
+
* - **Masking is inverted from the CLI**: on by default here, because the
|
|
37
|
+
* consumer is a model context that is transmitted to a provider and very often
|
|
38
|
+
* logged on the way. The opt-out is explicit (`mask: false`) and documented as
|
|
39
|
+
* a disclosure.
|
|
40
|
+
*
|
|
41
|
+
* The seam is the JSON envelope (`schema_version`), which is already Flecto's
|
|
42
|
+
* versioned machine-facing contract. When this moves to its own `flecto-mcp`
|
|
43
|
+
* package, only {@link makeCliRunner} changes — it locates the `flecto` binary
|
|
44
|
+
* from `node_modules` instead of being handed this repo's `index.js`. The
|
|
45
|
+
* protocol layer, the tool schemas, the validation, and the bounding all move
|
|
46
|
+
* verbatim. Nothing here imports Flecto's internals, so there is no private API
|
|
47
|
+
* to freeze first.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/** The MCP revision advertised when the client names none. */
|
|
51
|
+
export const DEFAULT_PROTOCOL_VERSION = '2025-06-18';
|
|
52
|
+
|
|
53
|
+
/** A result never returns more than this many changes or findings per file. */
|
|
54
|
+
export const MAX_ITEMS = 500;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The three read-only tools, exactly the sketch in #140. `inputSchema` is JSON
|
|
58
|
+
* Schema, which is what an MCP client renders and validates against.
|
|
59
|
+
*/
|
|
60
|
+
export const TOOLS = [
|
|
61
|
+
{
|
|
62
|
+
name: 'flecto_diff',
|
|
63
|
+
description:
|
|
64
|
+
'Semantic changes to one config file against a baseline (a git ref, default HEAD, '
|
|
65
|
+
+ 'or a path-shaped snapshot file). Returns the meaningful diff — the small answer — not the file. '
|
|
66
|
+
+ 'Secret-like values are masked by default.',
|
|
67
|
+
inputSchema: {
|
|
68
|
+
type: 'object',
|
|
69
|
+
properties: {
|
|
70
|
+
file: { type: 'string', description: 'Path to the config file, relative to the working directory.' },
|
|
71
|
+
ref: {
|
|
72
|
+
type: 'string',
|
|
73
|
+
description: 'Baseline to diff against: a git revision (default "HEAD"), or a snapshot file named as a path (absolute, ./ or ../).',
|
|
74
|
+
},
|
|
75
|
+
mask: {
|
|
76
|
+
type: 'boolean',
|
|
77
|
+
description: 'Mask secret-like values (default true). Set false only when the caller accepts disclosing them.',
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
required: ['file'],
|
|
81
|
+
additionalProperties: false,
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'flecto_check',
|
|
86
|
+
description:
|
|
87
|
+
'Policy findings for one or more config files (or globs), evaluated over their changes '
|
|
88
|
+
+ 'against HEAD. Optionally restrict to named policy packs. Secret-like values are masked by default.',
|
|
89
|
+
inputSchema: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
properties: {
|
|
92
|
+
files: {
|
|
93
|
+
type: 'array',
|
|
94
|
+
items: { type: 'string' },
|
|
95
|
+
description: 'Config file paths or globs, relative to the working directory.',
|
|
96
|
+
},
|
|
97
|
+
packs: {
|
|
98
|
+
type: 'array',
|
|
99
|
+
items: { type: 'string' },
|
|
100
|
+
description: 'Policy pack ids to evaluate (default: the packs configured in .flectorc).',
|
|
101
|
+
},
|
|
102
|
+
mask: { type: 'boolean', description: 'Mask secret-like values (default true).' },
|
|
103
|
+
},
|
|
104
|
+
required: ['files'],
|
|
105
|
+
additionalProperties: false,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'flecto_explain',
|
|
110
|
+
description:
|
|
111
|
+
'What changed at one configuration path in a file (e.g. "database.pool_size"), against a '
|
|
112
|
+
+ 'baseline (default HEAD): the before/after value and any policy findings that touch it. '
|
|
113
|
+
+ 'Secret-like values are masked by default.',
|
|
114
|
+
inputSchema: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
properties: {
|
|
117
|
+
file: { type: 'string', description: 'Path to the config file, relative to the working directory.' },
|
|
118
|
+
path: { type: 'string', description: 'The configuration path to explain, in dot/index notation.' },
|
|
119
|
+
ref: { type: 'string', description: 'Baseline to diff against: a git revision (default "HEAD"), or a snapshot file named as a path (absolute, ./ or ../).' },
|
|
120
|
+
mask: { type: 'boolean', description: 'Mask secret-like values (default true).' },
|
|
121
|
+
},
|
|
122
|
+
required: ['file', 'path'],
|
|
123
|
+
additionalProperties: false,
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Refuse a target argument that escapes the working directory before it is ever
|
|
130
|
+
* spawned. Globs are allowed (the CLI resolves and contains each match); a `..`
|
|
131
|
+
* segment or an absolute path outside `cwd` is not.
|
|
132
|
+
*
|
|
133
|
+
* Nor is a leading `-`. A file argument lands on the `ci` command line, and one
|
|
134
|
+
* spelled `--plugins=./p.mjs` or `--update-baseline` would be parsed as that
|
|
135
|
+
* option — code execution and a write from a single tool call. `ciArgs` already
|
|
136
|
+
* ends options with `--` before any file; this refuses the shape outright too, so
|
|
137
|
+
* the guarantee does not rest on one line of argv ordering.
|
|
138
|
+
* @param {unknown} arg
|
|
139
|
+
* @param {string} cwd
|
|
140
|
+
* @returns {string} the argument, when it is safe
|
|
141
|
+
*/
|
|
142
|
+
export function assertSafeTargetArg(arg, cwd) {
|
|
143
|
+
if (typeof arg !== 'string' || arg === '') {
|
|
144
|
+
throw new Error('a file argument must be a non-empty string');
|
|
145
|
+
}
|
|
146
|
+
if (arg.includes('\0')) throw new Error('a file argument must not contain a NUL byte');
|
|
147
|
+
if (arg.startsWith('-')) {
|
|
148
|
+
throw new Error(`"${arg}" starts with "-" and would be read as a CLI option; it is refused`);
|
|
149
|
+
}
|
|
150
|
+
const segments = arg.split(/[\\/]/);
|
|
151
|
+
if (segments.includes('..')) {
|
|
152
|
+
throw new Error(`"${arg}" escapes the working directory ("..") and is refused`);
|
|
153
|
+
}
|
|
154
|
+
if (isAbsolute(arg)) {
|
|
155
|
+
const resolved = resolve(arg);
|
|
156
|
+
const root = resolve(cwd);
|
|
157
|
+
if (resolved !== root && !resolved.startsWith(root + sep)) {
|
|
158
|
+
throw new Error(`"${arg}" is outside the working directory and is refused`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return arg;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The real path, following links; the lexical one when it cannot be resolved. */
|
|
165
|
+
function canonicalPath(path) {
|
|
166
|
+
try {
|
|
167
|
+
return realpathSync(path);
|
|
168
|
+
} catch {
|
|
169
|
+
return resolve(path);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A ref must be a plain value, not another flag and not a control character —
|
|
175
|
+
* it is passed to the CLI as the value of `--snapshot-ref`.
|
|
176
|
+
*
|
|
177
|
+
* It is also a *path*: `ci` reads a ref that names an existing file as a
|
|
178
|
+
* snapshot, resolved against the working directory. Uncontained, `ref:
|
|
179
|
+
* "/elsewhere/creds.json"` diffed that file and returned its values to the
|
|
180
|
+
* agent, and a non-JSON file leaked its opening bytes through the parse error.
|
|
181
|
+
* So a ref naming anything on disk gets the containment a file argument gets,
|
|
182
|
+
* checked on the real path so an in-repo symlink cannot point it outward.
|
|
183
|
+
* @param {unknown} ref
|
|
184
|
+
* @param {string} cwd
|
|
185
|
+
* @returns {string}
|
|
186
|
+
*/
|
|
187
|
+
function assertSafeRef(ref, cwd) {
|
|
188
|
+
if (typeof ref !== 'string' || ref === '') throw new Error('ref must be a non-empty string');
|
|
189
|
+
if (ref.startsWith('-')) throw new Error(`ref "${ref}" must not start with "-"`);
|
|
190
|
+
if (/[\0\n\r]/.test(ref)) throw new Error('ref must not contain a newline or NUL byte');
|
|
191
|
+
const asPath = resolve(cwd, ref);
|
|
192
|
+
if (existsSync(asPath)) {
|
|
193
|
+
const real = canonicalPath(asPath);
|
|
194
|
+
const root = canonicalPath(cwd);
|
|
195
|
+
if (real !== root && !real.startsWith(root + sep)) {
|
|
196
|
+
throw new Error(`ref "${ref}" names a file outside the working directory and is refused`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return ref;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Cap a list, reporting how much was withheld so a bounded result never claims
|
|
204
|
+
* to be the whole answer.
|
|
205
|
+
* @template T
|
|
206
|
+
* @param {T[]} items
|
|
207
|
+
* @param {number} [cap]
|
|
208
|
+
* @returns {{ items: T[], total: number, omitted: number, truncated: boolean }}
|
|
209
|
+
*/
|
|
210
|
+
export function bound(items, cap = MAX_ITEMS) {
|
|
211
|
+
const list = Array.isArray(items) ? items : [];
|
|
212
|
+
const kept = list.slice(0, cap);
|
|
213
|
+
return { items: kept, total: list.length, omitted: list.length - kept.length, truncated: list.length > kept.length };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Build the argv for a read-only `ci` run. This is the *only* place tool inputs
|
|
218
|
+
* become CLI arguments, so the read-only guarantee is auditable in one function:
|
|
219
|
+
* nothing here can emit a write, a webhook, a plugin path, or `--command`.
|
|
220
|
+
*
|
|
221
|
+
* That holds only if no tool input is *parsed* as an option. So every option is
|
|
222
|
+
* emitted first, agent-supplied values ride in `--name=value` form (never as a
|
|
223
|
+
* separate argument the parser could take for a flag), and `--` ends option
|
|
224
|
+
* parsing before the files, which are therefore always operands.
|
|
225
|
+
* @param {{ files: string[], ref?: string, packs?: string[], mask?: boolean }} spec
|
|
226
|
+
* @returns {string[]}
|
|
227
|
+
*/
|
|
228
|
+
function ciArgs({ files, ref, packs, mask }) {
|
|
229
|
+
const args = ['ci', `--snapshot-ref=${ref ?? 'HEAD'}`, '--format', 'json', '--allow-empty'];
|
|
230
|
+
if (mask !== false) args.push('--mask-secrets');
|
|
231
|
+
if (packs && packs.length > 0) args.push(`--policies=${packs.join(',')}`);
|
|
232
|
+
args.push('--', ...files);
|
|
233
|
+
return args;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Parse `ci --format json` output into the per-file results. `ci` exits non-zero
|
|
238
|
+
* whenever it finds a change or a finding — that is its gate, not an error — so
|
|
239
|
+
* the exit code is ignored and the presence of parseable stdout is the signal.
|
|
240
|
+
* A genuine failure (no baseline, unreadable file) prints `[error] …` to stderr
|
|
241
|
+
* and leaves stdout empty, which surfaces as a tool error.
|
|
242
|
+
* @param {{ status: number | null, stdout: string, stderr: string }} run
|
|
243
|
+
* @returns {Array<{ file: string, envelope: any, policies: any[] }>}
|
|
244
|
+
*/
|
|
245
|
+
function parseCiResults(run) {
|
|
246
|
+
const stdout = (run.stdout ?? '').trim();
|
|
247
|
+
if (!stdout) {
|
|
248
|
+
const detail = (run.stderr ?? '').trim() || `flecto exited ${run.status}`;
|
|
249
|
+
throw new Error(detail.replace(/^\[error\]\s*/, ''));
|
|
250
|
+
}
|
|
251
|
+
let parsed;
|
|
252
|
+
try {
|
|
253
|
+
parsed = JSON.parse(stdout);
|
|
254
|
+
} catch {
|
|
255
|
+
throw new Error(`could not parse flecto output: ${stdout.slice(0, 200)}`);
|
|
256
|
+
}
|
|
257
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** True when `changePath` is `target` or nested beneath it (`a.b`, `a[0]`). */
|
|
261
|
+
function pathMatches(changePath, target) {
|
|
262
|
+
if (changePath === target) return true;
|
|
263
|
+
return changePath.startsWith(`${target}.`) || changePath.startsWith(`${target}[`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* --------------------------------------------------------------- the tools */
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* @typedef {(args: string[]) => Promise<{ status: number | null, stdout: string, stderr: string }>} FlectoRunner
|
|
270
|
+
*/
|
|
271
|
+
|
|
272
|
+
/** @type {Record<string, (input: any, ctx: { runFlecto: FlectoRunner, cwd: string }) => Promise<object>>} */
|
|
273
|
+
const HANDLERS = {
|
|
274
|
+
async flecto_diff(input, { runFlecto, cwd }) {
|
|
275
|
+
const file = assertSafeTargetArg(input?.file, cwd);
|
|
276
|
+
const ref = input?.ref === undefined ? 'HEAD' : assertSafeRef(input.ref, cwd);
|
|
277
|
+
const run = await runFlecto(ciArgs({ files: [file], ref, mask: input?.mask }));
|
|
278
|
+
const results = parseCiResults(run);
|
|
279
|
+
const result = results.find((r) => r.file?.endsWith(file)) ?? results[0];
|
|
280
|
+
const changes = bound(result?.envelope?.changes ?? []);
|
|
281
|
+
return {
|
|
282
|
+
tool: 'flecto_diff',
|
|
283
|
+
file,
|
|
284
|
+
ref,
|
|
285
|
+
masked: input?.mask !== false,
|
|
286
|
+
changeCount: changes.total,
|
|
287
|
+
changes: changes.items,
|
|
288
|
+
...(changes.truncated ? { truncated: { changes: changes.omitted } } : {}),
|
|
289
|
+
policies: bound(result?.policies ?? []).items,
|
|
290
|
+
};
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
async flecto_check(input, { runFlecto, cwd }) {
|
|
294
|
+
if (!Array.isArray(input?.files) || input.files.length === 0) {
|
|
295
|
+
throw new Error('files must be a non-empty array');
|
|
296
|
+
}
|
|
297
|
+
const files = input.files.map((f) => assertSafeTargetArg(f, cwd));
|
|
298
|
+
const packs = Array.isArray(input?.packs) ? input.packs.map(String) : undefined;
|
|
299
|
+
const run = await runFlecto(ciArgs({ files, ref: 'HEAD', packs, mask: input?.mask }));
|
|
300
|
+
const results = parseCiResults(run);
|
|
301
|
+
const findings = results.flatMap((r) => (r.policies ?? []).map((finding) => ({ file: r.file, ...finding })));
|
|
302
|
+
const capped = bound(findings);
|
|
303
|
+
return {
|
|
304
|
+
tool: 'flecto_check',
|
|
305
|
+
files,
|
|
306
|
+
...(packs ? { packs } : {}),
|
|
307
|
+
masked: input?.mask !== false,
|
|
308
|
+
findingCount: capped.total,
|
|
309
|
+
findings: capped.items,
|
|
310
|
+
...(capped.truncated ? { truncated: { findings: capped.omitted } } : {}),
|
|
311
|
+
};
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
async flecto_explain(input, { runFlecto, cwd }) {
|
|
315
|
+
const file = assertSafeTargetArg(input?.file, cwd);
|
|
316
|
+
if (typeof input?.path !== 'string' || input.path === '') {
|
|
317
|
+
throw new Error('path must be a non-empty string');
|
|
318
|
+
}
|
|
319
|
+
const target = input.path;
|
|
320
|
+
const ref = input?.ref === undefined ? 'HEAD' : assertSafeRef(input.ref, cwd);
|
|
321
|
+
const run = await runFlecto(ciArgs({ files: [file], ref, mask: input?.mask }));
|
|
322
|
+
const results = parseCiResults(run);
|
|
323
|
+
const result = results.find((r) => r.file?.endsWith(file)) ?? results[0];
|
|
324
|
+
const changes = (result?.envelope?.changes ?? []).filter((c) => pathMatches(String(c.path ?? ''), target));
|
|
325
|
+
const findings = (result?.policies ?? []).filter((f) => pathMatches(String(f.path ?? ''), target));
|
|
326
|
+
return {
|
|
327
|
+
tool: 'flecto_explain',
|
|
328
|
+
file,
|
|
329
|
+
path: target,
|
|
330
|
+
ref,
|
|
331
|
+
masked: input?.mask !== false,
|
|
332
|
+
changed: changes.length > 0,
|
|
333
|
+
changes: bound(changes).items,
|
|
334
|
+
findings: bound(findings).items,
|
|
335
|
+
...(changes.length === 0 ? { note: `No change at "${target}" against ${ref}.` } : {}),
|
|
336
|
+
};
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
/* ------------------------------------------------------------ JSON-RPC core */
|
|
341
|
+
|
|
342
|
+
const jsonrpcError = (id, code, message) => ({ jsonrpc: '2.0', id: id ?? null, error: { code, message } });
|
|
343
|
+
const jsonrpcResult = (id, result) => ({ jsonrpc: '2.0', id, result });
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* A dispatcher over parsed JSON-RPC messages, with no I/O of its own so it can
|
|
347
|
+
* be driven directly in tests. Returns the response object, or `null` for a
|
|
348
|
+
* notification (which gets none).
|
|
349
|
+
* @param {{ version: string, cwd: string, runFlecto: FlectoRunner }} ctx
|
|
350
|
+
*/
|
|
351
|
+
export function createServer({ version, cwd, runFlecto }) {
|
|
352
|
+
let protocolVersion = DEFAULT_PROTOCOL_VERSION;
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
/**
|
|
356
|
+
* @param {any} msg a parsed JSON-RPC message
|
|
357
|
+
* @returns {Promise<object | null>}
|
|
358
|
+
*/
|
|
359
|
+
async handle(msg) {
|
|
360
|
+
if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
|
|
361
|
+
return jsonrpcError(msg?.id, -32600, 'Invalid Request');
|
|
362
|
+
}
|
|
363
|
+
const { id, method, params } = msg;
|
|
364
|
+
const isNotification = id === undefined || id === null;
|
|
365
|
+
|
|
366
|
+
switch (method) {
|
|
367
|
+
case 'initialize': {
|
|
368
|
+
if (typeof params?.protocolVersion === 'string') protocolVersion = params.protocolVersion;
|
|
369
|
+
return jsonrpcResult(id, {
|
|
370
|
+
protocolVersion,
|
|
371
|
+
capabilities: { tools: {} },
|
|
372
|
+
serverInfo: { name: 'flecto', version },
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
case 'ping':
|
|
376
|
+
return jsonrpcResult(id, {});
|
|
377
|
+
case 'tools/list':
|
|
378
|
+
return jsonrpcResult(id, { tools: TOOLS });
|
|
379
|
+
case 'tools/call': {
|
|
380
|
+
const name = params?.name;
|
|
381
|
+
const handler = HANDLERS[name];
|
|
382
|
+
if (!handler) {
|
|
383
|
+
return jsonrpcResult(id, {
|
|
384
|
+
content: [{ type: 'text', text: `Unknown tool: ${String(name)}` }],
|
|
385
|
+
isError: true,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
const payload = await handler(params?.arguments ?? {}, { runFlecto, cwd });
|
|
390
|
+
return jsonrpcResult(id, { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] });
|
|
391
|
+
} catch (err) {
|
|
392
|
+
// A tool-level failure is returned as an error *result*, not a
|
|
393
|
+
// JSON-RPC error, so the model sees the reason and can adjust.
|
|
394
|
+
return jsonrpcResult(id, {
|
|
395
|
+
content: [{ type: 'text', text: `flecto ${name} failed: ${err.message}` }],
|
|
396
|
+
isError: true,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
default:
|
|
401
|
+
// Notifications we do not act on (e.g. notifications/initialized) get
|
|
402
|
+
// no response, per JSON-RPC; unknown requests get method-not-found.
|
|
403
|
+
if (isNotification) return null;
|
|
404
|
+
return jsonrpcError(id, -32601, `Method not found: ${method}`);
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The default runner: spawn the read-only `flecto` CLI. `FLECTO_ALLOW_RC_PLUGINS`,
|
|
412
|
+
* `FLECTO_ALLOW_RC_WRITES`, and `FLECTO_ALLOW_SYMLINK_TARGETS` are stripped from
|
|
413
|
+
* the child so none can be turned on for a tool call, whatever the environment
|
|
414
|
+
* holds.
|
|
415
|
+
* @param {{ nodeExec: string, cliPath: string, cwd: string }} opts
|
|
416
|
+
* @returns {FlectoRunner}
|
|
417
|
+
*/
|
|
418
|
+
export function makeCliRunner({ nodeExec, cliPath, cwd }) {
|
|
419
|
+
return async (args) => {
|
|
420
|
+
const env = { ...process.env };
|
|
421
|
+
delete env.FLECTO_ALLOW_RC_PLUGINS;
|
|
422
|
+
delete env.FLECTO_ALLOW_RC_WRITES;
|
|
423
|
+
// The symlink-escape check is one of the two containment layers a tool
|
|
424
|
+
// call relies on, so the operator's opt-out does not carry into it either.
|
|
425
|
+
delete env.FLECTO_ALLOW_SYMLINK_TARGETS;
|
|
426
|
+
const run = spawnSync(nodeExec, [cliPath, ...args], {
|
|
427
|
+
cwd,
|
|
428
|
+
env,
|
|
429
|
+
encoding: 'utf8',
|
|
430
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
431
|
+
});
|
|
432
|
+
return { status: run.status, stdout: run.stdout ?? '', stderr: run.stderr ?? '' };
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Serve the MCP protocol over stdio: newline-delimited JSON-RPC in, the same
|
|
438
|
+
* out. All diagnostics go to stderr so stdout carries protocol only.
|
|
439
|
+
* @param {{ version: string, cwd?: string, runFlecto: FlectoRunner, input?: NodeJS.ReadableStream, output?: NodeJS.WritableStream, onLog?: (msg: string) => void }} opts
|
|
440
|
+
* @returns {Promise<void>} resolves when the input stream ends
|
|
441
|
+
*/
|
|
442
|
+
export function runStdioServer({ version, cwd = process.cwd(), runFlecto, input = process.stdin, output = process.stdout, onLog = (m) => process.stderr.write(`${m}\n`) }) {
|
|
443
|
+
const server = createServer({ version, cwd, runFlecto });
|
|
444
|
+
let buffer = '';
|
|
445
|
+
|
|
446
|
+
const send = (message) => output.write(`${JSON.stringify(message)}\n`);
|
|
447
|
+
|
|
448
|
+
const processLine = async (line) => {
|
|
449
|
+
const trimmed = line.trim();
|
|
450
|
+
if (!trimmed) return;
|
|
451
|
+
let msg;
|
|
452
|
+
try {
|
|
453
|
+
msg = JSON.parse(trimmed);
|
|
454
|
+
} catch {
|
|
455
|
+
send(jsonrpcError(null, -32700, 'Parse error'));
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
const response = await server.handle(msg);
|
|
460
|
+
if (response) send(response);
|
|
461
|
+
} catch (err) {
|
|
462
|
+
onLog(`handler error: ${err.stack ?? err.message}`);
|
|
463
|
+
if (msg && msg.id !== undefined && msg.id !== null) {
|
|
464
|
+
send(jsonrpcError(msg.id, -32603, 'Internal error'));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
return new Promise((resolveDone) => {
|
|
470
|
+
input.setEncoding('utf8');
|
|
471
|
+
// Lines are processed strictly in order: a chain of promises so a slow tool
|
|
472
|
+
// call cannot interleave its response with the next line's.
|
|
473
|
+
let chain = Promise.resolve();
|
|
474
|
+
input.on('data', (chunk) => {
|
|
475
|
+
buffer += chunk;
|
|
476
|
+
let index;
|
|
477
|
+
while ((index = buffer.indexOf('\n')) !== -1) {
|
|
478
|
+
const line = buffer.slice(0, index);
|
|
479
|
+
buffer = buffer.slice(index + 1);
|
|
480
|
+
chain = chain.then(() => processLine(line));
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
input.on('end', () => {
|
|
484
|
+
chain = chain.then(() => processLine(buffer)).then(() => resolveDone());
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
}
|
package/src/parser.js
CHANGED
|
@@ -380,8 +380,28 @@ function documentKeys(docs) {
|
|
|
380
380
|
*/
|
|
381
381
|
export function parseYamlStream(raw) {
|
|
382
382
|
const docs = yaml.loadAll(raw).filter((doc) => doc != null);
|
|
383
|
+
const keys = yamlDocumentKeys(docs);
|
|
384
|
+
if (keys === null) return withDocumentKeys(docs[0], []);
|
|
383
385
|
|
|
384
|
-
|
|
386
|
+
/** @type {Record<string, unknown>} */
|
|
387
|
+
const out = {};
|
|
388
|
+
for (let i = 0; i < docs.length; i++) {
|
|
389
|
+
out[keys[i]] = docs[i];
|
|
390
|
+
}
|
|
391
|
+
return withDocumentKeys(out, keys);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* How {@link parseYamlStream} lays out a stream: `null` when a lone document is
|
|
396
|
+
* returned bare, otherwise the key each document is stored under. The position
|
|
397
|
+
* index (positions.js) addresses documents through this same function, so a
|
|
398
|
+
* diagnostic can never disagree with the differ about which document a path is
|
|
399
|
+
* in.
|
|
400
|
+
* @param {unknown[]} docs the stream's non-empty documents
|
|
401
|
+
* @returns {string[] | null}
|
|
402
|
+
*/
|
|
403
|
+
export function yamlDocumentKeys(docs) {
|
|
404
|
+
if (docs.length === 0) return [];
|
|
385
405
|
|
|
386
406
|
// A lone document is normally returned bare, preserving ordinary YAML paths.
|
|
387
407
|
// The exception is a Kubernetes manifest with a resolvable identity: keying it
|
|
@@ -389,21 +409,11 @@ export function parseYamlStream(raw) {
|
|
|
389
409
|
// of re-pathing the whole file and reporting the untouched resource as
|
|
390
410
|
// removed-and-re-added. Ordinary single-document config is unaffected.
|
|
391
411
|
if (docs.length === 1) {
|
|
392
|
-
const [
|
|
393
|
-
|
|
394
|
-
if (identity == null || identity === '__proto__') {
|
|
395
|
-
return withDocumentKeys(doc, []);
|
|
396
|
-
}
|
|
397
|
-
return withDocumentKeys({ [identity]: doc }, [identity]);
|
|
412
|
+
const identity = isKubernetesDocument(docs[0]) ? documentIdentity(docs[0]) : null;
|
|
413
|
+
return identity == null || identity === '__proto__' ? null : [identity];
|
|
398
414
|
}
|
|
399
415
|
|
|
400
|
-
|
|
401
|
-
/** @type {Record<string, unknown>} */
|
|
402
|
-
const out = {};
|
|
403
|
-
for (let i = 0; i < docs.length; i++) {
|
|
404
|
-
out[keys[i]] = docs[i];
|
|
405
|
-
}
|
|
406
|
-
return withDocumentKeys(out, keys);
|
|
416
|
+
return documentKeys(docs);
|
|
407
417
|
}
|
|
408
418
|
|
|
409
419
|
/**
|