@mario.andreschak/mcp-filesystem 3.39.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/dist/index.d.ts +3 -0
- package/dist/index.js +56 -0
- package/dist/index.js.map +1 -0
- package/dist/resources.d.ts +49 -0
- package/dist/resources.js +604 -0
- package/dist/resources.js.map +1 -0
- package/dist/shared.d.ts +24 -0
- package/dist/shared.js +161 -0
- package/dist/shared.js.map +1 -0
- package/dist/tools.d.ts +3 -0
- package/dist/tools.js +1229 -0
- package/dist/tools.js.map +1 -0
- package/package.json +30 -0
package/dist/tools.js
ADDED
|
@@ -0,0 +1,1229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in `filesystem` MCP server (issue #170).
|
|
3
|
+
*
|
|
4
|
+
* Cross-platform (Windows/macOS/Linux) filesystem access with:
|
|
5
|
+
* - structured JSON outputs (MCP `structuredContent` + a text fallback block),
|
|
6
|
+
* - line-targeted reads AND writes/edits,
|
|
7
|
+
* - literal find/replace edits AND real unified-diff (`@@`) patch apply,
|
|
8
|
+
* - directory listing/tree and name/content search.
|
|
9
|
+
*
|
|
10
|
+
* Relative paths resolve against the FLUJO data directory; absolute paths are
|
|
11
|
+
* honored as-is (same host-access posture as the legacy `terminal` tool). Two
|
|
12
|
+
* layers of confinement can narrow that:
|
|
13
|
+
* - the FLUJO_FS_ROOTS env (path-list separated by the OS path delimiter) acts
|
|
14
|
+
* as a HARD CEILING an operator sets — no path may ever escape it, and
|
|
15
|
+
* - user-configured roots persisted via the MCP manager UI (issue #170), which
|
|
16
|
+
* may only narrow WITHIN the env ceiling (never widen it).
|
|
17
|
+
* When neither is set the server has full host access. An optional experimental
|
|
18
|
+
* deny layer (issue #260, see protectedPaths.ts) can additionally block a
|
|
19
|
+
* hardcoded set of sensitive home locations regardless of configured roots. It
|
|
20
|
+
* is disabled by default.
|
|
21
|
+
*
|
|
22
|
+
* Every tool returns a machine-readable JSON envelope both as MCP
|
|
23
|
+
* `structuredContent` and as a single text content block (for backward-compat
|
|
24
|
+
* clients); errors are returned as `isError: true` results rather than thrown.
|
|
25
|
+
*/
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { promises as fs, createReadStream } from 'node:fs';
|
|
28
|
+
import { createHash } from 'node:crypto';
|
|
29
|
+
import readline from 'node:readline';
|
|
30
|
+
import { ALLOW_PROTECTED_PATHS_ENV, createLogger, getDataDir, isInside, isProtected, isProtectedPathsEnabled, loadEffectiveRoots, } from './shared.js';
|
|
31
|
+
import { recordTouchedFile } from './resources.js';
|
|
32
|
+
const FILESYSTEM_SERVER_NAME = 'filesystem';
|
|
33
|
+
const log = createLogger('backend/services/mcp/internal/filesystemTools');
|
|
34
|
+
/** Output cap so a huge file/listing can't flood the model's context. */
|
|
35
|
+
const MAX_READ_CHARS = 200_000;
|
|
36
|
+
/** #316: bound batch fan-out and its cumulative serialized response size. */
|
|
37
|
+
const MAX_BATCH_READ_FILES = 25;
|
|
38
|
+
const MAX_BATCH_READ_CHARS = 1_000_000;
|
|
39
|
+
const MAX_SEARCH_RESULTS = 1_000;
|
|
40
|
+
const DEFAULT_TREE_DEPTH = 3;
|
|
41
|
+
const MAX_TREE_DEPTH = 10;
|
|
42
|
+
const MAX_TREE_ENTRIES = 5_000;
|
|
43
|
+
// Advertise the real side-effect profile to MCP clients. Codex in particular
|
|
44
|
+
// uses these hints to distinguish safe discovery/reads from mutations instead
|
|
45
|
+
// of treating every FLUJO filesystem operation as an unknown-risk action.
|
|
46
|
+
const READ_ONLY_ANNOTATIONS = {
|
|
47
|
+
readOnlyHint: true,
|
|
48
|
+
destructiveHint: false,
|
|
49
|
+
idempotentHint: true,
|
|
50
|
+
openWorldHint: false,
|
|
51
|
+
};
|
|
52
|
+
const WRITE_ANNOTATIONS = {
|
|
53
|
+
readOnlyHint: false,
|
|
54
|
+
destructiveHint: false,
|
|
55
|
+
openWorldHint: false,
|
|
56
|
+
};
|
|
57
|
+
const DESTRUCTIVE_WRITE_ANNOTATIONS = {
|
|
58
|
+
readOnlyHint: false,
|
|
59
|
+
destructiveHint: true,
|
|
60
|
+
openWorldHint: false,
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* #287: files at/above this size may not be read whole without an explicit
|
|
64
|
+
* `pattern` (grep first, then targeted line reads) — or a `*` opt-in. Keeps a
|
|
65
|
+
* large file from silently flooding the model's context on a bare read.
|
|
66
|
+
*/
|
|
67
|
+
const LARGE_FILE_BYTES = 100_000;
|
|
68
|
+
/** #287: skip files bigger than this during content search (perf/binary guard). */
|
|
69
|
+
const SEARCH_MAX_FILE_BYTES = 5_000_000;
|
|
70
|
+
/** #287: how many files to scan concurrently during a content search. */
|
|
71
|
+
const SEARCH_CONCURRENCY = 16;
|
|
72
|
+
/** #287: context lines shown around each `read_file` pattern match. */
|
|
73
|
+
const READ_PATTERN_CONTEXT = 2;
|
|
74
|
+
/** #287: cap on matches returned by a single `read_file` pattern grep. */
|
|
75
|
+
const MAX_READ_PATTERN_MATCHES = 200;
|
|
76
|
+
/** Return a structured payload as BOTH MCP structuredContent and a text fallback. */
|
|
77
|
+
function dualResult(payload) {
|
|
78
|
+
return {
|
|
79
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
80
|
+
structuredContent: payload,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Return a plain string/error message (no structuredContent). */
|
|
84
|
+
function textResult(message, isError = false) {
|
|
85
|
+
return {
|
|
86
|
+
content: [{ type: 'text', text: message }],
|
|
87
|
+
...(isError ? { isError: true } : {}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** Return an error envelope (JSON text + isError). */
|
|
91
|
+
function errorResult(message) {
|
|
92
|
+
return {
|
|
93
|
+
content: [{ type: 'text', text: JSON.stringify({ error: message }, null, 2) }],
|
|
94
|
+
isError: true,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Resolve a user-supplied path against the data dir (for relative paths) and
|
|
99
|
+
* enforce the effective confinement roots. An empty roots array blocks all access.
|
|
100
|
+
* Throws on a confinement violation so callers surface a precise error.
|
|
101
|
+
*/
|
|
102
|
+
async function resolvePath(input, roots) {
|
|
103
|
+
const raw = typeof input === 'string' ? input.trim() : '';
|
|
104
|
+
if (!raw)
|
|
105
|
+
throw new Error('Provide "path".');
|
|
106
|
+
const dataDir = getDataDir();
|
|
107
|
+
const resolved = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(dataDir, raw);
|
|
108
|
+
// Optional defense-in-depth layer (issue #260). Configured roots win by
|
|
109
|
+
// default; users can opt into this stricter policy under Experimental Features.
|
|
110
|
+
if (await isProtectedPathsEnabled()) {
|
|
111
|
+
const prot = isProtected(resolved);
|
|
112
|
+
if (prot.denied) {
|
|
113
|
+
throw new Error(`Path "${resolved}" is within a protected location (${prot.matchedRoot}) and is blocked by the FLUJO built-in server protected-path policy. ` +
|
|
114
|
+
`Disable "Protect sensitive home-directory paths" in Experimental Features or set ${ALLOW_PROTECTED_PATHS_ENV}=1 to override.`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (roots.length === 0 || !roots.some((root) => isInside(root, resolved))) {
|
|
118
|
+
throw new Error(`Path "${resolved}" is outside the configured filesystem roots.`);
|
|
119
|
+
}
|
|
120
|
+
return resolved;
|
|
121
|
+
}
|
|
122
|
+
export function filesystemToolDefinitions() {
|
|
123
|
+
const pathProp = { type: 'string', description: 'File or directory path. Relative paths resolve against the FLUJO data directory; absolute paths are used as-is.' };
|
|
124
|
+
return [
|
|
125
|
+
{
|
|
126
|
+
name: 'read_file',
|
|
127
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
128
|
+
description: 'Read one text file with "path", or up to 25 files in input order with mutually exclusive "paths". Optional "from"/"to" (1-based, inclusive) and "pattern" settings apply to every target. ' +
|
|
129
|
+
`For large files (> ${LARGE_FILE_BYTES / 1000} KB) read WHOLE (no "from"/"to"), a "pattern" is REQUIRED: the server greps the file and returns only matching lines (with a little surrounding context) so you can follow up with targeted "from"/"to" reads. Pass pattern "*" to force-read the entire large file anyway. ` +
|
|
130
|
+
'Single reads return { path, from, to, totalLines, content, truncated, contentHash, matches? } unchanged. Batch reads return { files }, with one ordered success or { requestedPath, path?, error } record per input; individual failures do not discard successful reads. Batch output is limited to 1,000,000 serialized characters. Pass contentHash back as "expectedHash" on a follow-up edit_file/write_file to guard against the file changing in between (TOCTOU).',
|
|
131
|
+
inputSchema: {
|
|
132
|
+
type: 'object',
|
|
133
|
+
properties: {
|
|
134
|
+
path: pathProp,
|
|
135
|
+
paths: {
|
|
136
|
+
type: 'array',
|
|
137
|
+
minItems: 1,
|
|
138
|
+
maxItems: MAX_BATCH_READ_FILES,
|
|
139
|
+
items: { type: 'string' },
|
|
140
|
+
description: 'File paths to read in input order. Mutually exclusive with "path".',
|
|
141
|
+
},
|
|
142
|
+
from: { type: 'number', description: 'Optional 1-based first line to return (inclusive).' },
|
|
143
|
+
to: { type: 'number', description: 'Optional 1-based last line to return (inclusive).' },
|
|
144
|
+
pattern: { type: 'string', description: 'Case-insensitive substring to grep for. Required to read a large file whole without a "from"/"to" range; matching lines (plus context) are returned instead of the full body. Use "*" to read the whole large file anyway.' },
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
outputSchema: {
|
|
148
|
+
type: 'object',
|
|
149
|
+
properties: {
|
|
150
|
+
path: { type: 'string' },
|
|
151
|
+
files: {
|
|
152
|
+
type: 'array',
|
|
153
|
+
description: 'Ordered batch results. Successful entries use the single-file payload; failures include requestedPath and error.',
|
|
154
|
+
items: { type: 'object' },
|
|
155
|
+
},
|
|
156
|
+
from: { type: 'number' },
|
|
157
|
+
to: { type: 'number' },
|
|
158
|
+
totalLines: { type: 'number' },
|
|
159
|
+
truncated: { type: 'boolean' },
|
|
160
|
+
content: { type: 'string' },
|
|
161
|
+
contentHash: { type: 'string', description: 'SHA-256 (hex) of the raw file content. Pass this back as "expectedHash" to edit_file/write_file to guard against the file changing between read and write (TOCTOU).' },
|
|
162
|
+
matches: {
|
|
163
|
+
type: 'array',
|
|
164
|
+
items: {
|
|
165
|
+
type: 'object',
|
|
166
|
+
properties: { line: { type: 'number' }, text: { type: 'string' } },
|
|
167
|
+
required: ['line', 'text'],
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: 'write_file',
|
|
175
|
+
annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
|
|
176
|
+
description: 'Create or write a text file (parent directories are created). "mode": "overwrite" (default) replaces the whole file, or a line range when "startLine"/"endLine" (1-based inclusive) are given; "append" adds content at the end; "insert" inserts content before "startLine". Optionally pass "expectedHash" (the contentHash from a prior read_file) to reject the write if the file changed on disk since it was read (TOCTOU guard); ignored for a whole-file overwrite. Returns { path, bytesWritten, mode, ... }.',
|
|
177
|
+
// #216: feed the docked diff canvas (ui://devcanvas/diff) so successive
|
|
178
|
+
// writes update one persistent tab. See internal/filesystemResources.ts.
|
|
179
|
+
_meta: { ui: { resourceUri: 'ui://devcanvas/diff' } },
|
|
180
|
+
inputSchema: {
|
|
181
|
+
type: 'object',
|
|
182
|
+
properties: {
|
|
183
|
+
path: pathProp,
|
|
184
|
+
content: { type: 'string', description: 'The content to write/insert/append.' },
|
|
185
|
+
mode: { type: 'string', enum: ['overwrite', 'append', 'insert'], description: 'Write mode (default "overwrite").' },
|
|
186
|
+
startLine: { type: 'number', description: 'For "insert": line to insert before. For "overwrite": first line of the range to replace (1-based).' },
|
|
187
|
+
endLine: { type: 'number', description: 'For "overwrite": last line (inclusive, 1-based) of the range to replace.' },
|
|
188
|
+
expectedHash: { type: 'string', description: 'Optional SHA-256 (contentHash from read_file). For append/insert/range-overwrite modes, the write is rejected if the file no longer hashes to this value (TOCTOU guard).' },
|
|
189
|
+
},
|
|
190
|
+
required: ['path', 'content'],
|
|
191
|
+
},
|
|
192
|
+
outputSchema: {
|
|
193
|
+
type: 'object',
|
|
194
|
+
properties: {
|
|
195
|
+
path: { type: 'string' },
|
|
196
|
+
bytesWritten: { type: 'number' },
|
|
197
|
+
mode: { type: 'string' },
|
|
198
|
+
startLine: { type: 'number' },
|
|
199
|
+
endLine: { type: 'number' },
|
|
200
|
+
linesReplaced: { type: 'number' },
|
|
201
|
+
linesInserted: { type: 'number' },
|
|
202
|
+
},
|
|
203
|
+
required: ['path', 'bytesWritten', 'mode'],
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: 'edit_file',
|
|
208
|
+
annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
|
|
209
|
+
description: 'Edit a text file two ways (mutually exclusive): (1) "edits": [{ oldText, newText, startLine?, endLine? }] literal find/replace — each replaces the unique occurrence of oldText. startLine/endLine are an optional disambiguation HINT (if exactly one match starts in that range it wins); a wrong/missing range still works as long as oldText is unique in the file. Include enough surrounding context to make oldText unique. Or (2) "diff": a unified diff string ("@@ -a,b +c,d @@" hunks) applied atomically — hunks are relocated to where their context actually matches, so slightly-off @@ line numbers still apply, and CRLF files are handled. Fails with no partial write only when text is missing/ambiguous or a hunk context is not found. Optionally pass "expectedHash" (the contentHash from a prior read_file) to reject the edit if the file changed on disk since it was read (TOCTOU guard). Returns { path, editsApplied|applied, diff:{added,removed} }.',
|
|
210
|
+
// #216: also feed the docked diff canvas so edits show live in the canvas.
|
|
211
|
+
_meta: { ui: { resourceUri: 'ui://devcanvas/diff' } },
|
|
212
|
+
inputSchema: {
|
|
213
|
+
type: 'object',
|
|
214
|
+
properties: {
|
|
215
|
+
path: pathProp,
|
|
216
|
+
edits: {
|
|
217
|
+
type: 'array',
|
|
218
|
+
description: 'List of literal { oldText, newText } edits applied in order (optionally scoped with startLine/endLine).',
|
|
219
|
+
items: {
|
|
220
|
+
type: 'object',
|
|
221
|
+
properties: {
|
|
222
|
+
oldText: { type: 'string', description: 'Exact text to find.' },
|
|
223
|
+
newText: { type: 'string', description: 'Replacement text.' },
|
|
224
|
+
startLine: { type: 'number', description: 'Optional 1-based first line to scope this edit to.' },
|
|
225
|
+
endLine: { type: 'number', description: 'Optional 1-based last line (inclusive) to scope this edit to.' },
|
|
226
|
+
},
|
|
227
|
+
required: ['oldText', 'newText'],
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
diff: { type: 'string', description: 'A unified diff to apply atomically. Mutually exclusive with "edits".' },
|
|
231
|
+
startLine: { type: 'number', description: 'Optional default 1-based first line to scope all literal edits to.' },
|
|
232
|
+
endLine: { type: 'number', description: 'Optional default 1-based last line (inclusive) to scope all literal edits to.' },
|
|
233
|
+
expectedHash: { type: 'string', description: 'Optional SHA-256 (contentHash from read_file). If provided and the freshly-read file no longer hashes to it, the edit is rejected with no changes written (TOCTOU guard).' },
|
|
234
|
+
},
|
|
235
|
+
required: ['path'],
|
|
236
|
+
},
|
|
237
|
+
outputSchema: {
|
|
238
|
+
type: 'object',
|
|
239
|
+
properties: {
|
|
240
|
+
path: { type: 'string' },
|
|
241
|
+
mode: { type: 'string' },
|
|
242
|
+
editsApplied: { type: 'number' },
|
|
243
|
+
applied: { type: 'boolean' },
|
|
244
|
+
diff: {
|
|
245
|
+
type: 'object',
|
|
246
|
+
properties: { added: { type: 'number' }, removed: { type: 'number' } },
|
|
247
|
+
required: ['added', 'removed'],
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
required: ['path', 'diff'],
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
name: 'list_dir',
|
|
255
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
256
|
+
description: 'List the entries of a directory. Returns { path, entries: [{ name, type, size }] } where type is "file" | "directory" | "other".',
|
|
257
|
+
inputSchema: {
|
|
258
|
+
type: 'object',
|
|
259
|
+
properties: { path: pathProp },
|
|
260
|
+
required: ['path'],
|
|
261
|
+
},
|
|
262
|
+
outputSchema: {
|
|
263
|
+
type: 'object',
|
|
264
|
+
properties: {
|
|
265
|
+
path: { type: 'string' },
|
|
266
|
+
entries: {
|
|
267
|
+
type: 'array',
|
|
268
|
+
items: {
|
|
269
|
+
type: 'object',
|
|
270
|
+
properties: { name: { type: 'string' }, type: { type: 'string' }, size: { type: 'number' } },
|
|
271
|
+
required: ['name', 'type', 'size'],
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
required: ['path', 'entries'],
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
name: 'file_browser_ui',
|
|
280
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
281
|
+
description: 'Open an interactive file browser in the chat so the USER can pick a file. This returns IMMEDIATELY without a selection — the browser is shown to the user and the file they choose arrives afterwards as a follow-up user message (e.g. "Selected file: <path>"). After calling this tool you MUST stop and wait for that message; do not guess a path or continue until the user has selected.',
|
|
282
|
+
// MCP Apps (#97): this tool exists solely to surface the file-browser app
|
|
283
|
+
// (ui://filesystem/browser). See internal/filesystemResources.ts.
|
|
284
|
+
_meta: { ui: { resourceUri: 'ui://filesystem/browser' } },
|
|
285
|
+
inputSchema: {
|
|
286
|
+
type: 'object',
|
|
287
|
+
properties: {
|
|
288
|
+
path: { ...pathProp, description: 'Optional starting directory for the browser (defaults to the FLUJO data directory).' },
|
|
289
|
+
},
|
|
290
|
+
required: [],
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
name: 'dir_tree',
|
|
295
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
296
|
+
description: 'Return a recursive, depth-limited directory tree as nested JSON. Returns { path, depth, tree }.',
|
|
297
|
+
inputSchema: {
|
|
298
|
+
type: 'object',
|
|
299
|
+
properties: {
|
|
300
|
+
path: pathProp,
|
|
301
|
+
depth: { type: 'number', description: `Maximum recursion depth (default ${DEFAULT_TREE_DEPTH}, max ${MAX_TREE_DEPTH}).` },
|
|
302
|
+
},
|
|
303
|
+
required: ['path'],
|
|
304
|
+
},
|
|
305
|
+
outputSchema: {
|
|
306
|
+
type: 'object',
|
|
307
|
+
properties: {
|
|
308
|
+
path: { type: 'string' },
|
|
309
|
+
depth: { type: 'number' },
|
|
310
|
+
truncated: { type: 'boolean' },
|
|
311
|
+
tree: { type: 'array' },
|
|
312
|
+
},
|
|
313
|
+
required: ['path', 'depth', 'tree'],
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
name: 'search',
|
|
318
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
319
|
+
description: 'Search a directory tree. Match file/dir NAMES against "namePattern" (substring, case-insensitive) and/or file CONTENT against "content" (substring, case-insensitive). Content search DOES look inside every text file, returning each matching line with its 1-based line number; likely-binary and very large files are skipped for speed. Returns { matches: [{ path, line?, text? }], truncated }.',
|
|
320
|
+
inputSchema: {
|
|
321
|
+
type: 'object',
|
|
322
|
+
properties: {
|
|
323
|
+
path: pathProp,
|
|
324
|
+
namePattern: { type: 'string', description: 'Optional case-insensitive substring to match against entry names.' },
|
|
325
|
+
content: { type: 'string', description: 'Optional case-insensitive substring to match inside text files.' },
|
|
326
|
+
},
|
|
327
|
+
required: ['path'],
|
|
328
|
+
},
|
|
329
|
+
outputSchema: {
|
|
330
|
+
type: 'object',
|
|
331
|
+
properties: {
|
|
332
|
+
matches: {
|
|
333
|
+
type: 'array',
|
|
334
|
+
items: {
|
|
335
|
+
type: 'object',
|
|
336
|
+
properties: { path: { type: 'string' }, line: { type: 'number' }, text: { type: 'string' } },
|
|
337
|
+
required: ['path'],
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
truncated: { type: 'boolean' },
|
|
341
|
+
},
|
|
342
|
+
required: ['matches', 'truncated'],
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
name: 'get_file_info',
|
|
347
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
348
|
+
description: 'Stat a path. Returns { path, type, size, isFile, isDirectory, createdAt, modifiedAt }.',
|
|
349
|
+
inputSchema: {
|
|
350
|
+
type: 'object',
|
|
351
|
+
properties: { path: pathProp },
|
|
352
|
+
required: ['path'],
|
|
353
|
+
},
|
|
354
|
+
outputSchema: {
|
|
355
|
+
type: 'object',
|
|
356
|
+
properties: {
|
|
357
|
+
path: { type: 'string' },
|
|
358
|
+
type: { type: 'string' },
|
|
359
|
+
size: { type: 'number' },
|
|
360
|
+
isFile: { type: 'boolean' },
|
|
361
|
+
isDirectory: { type: 'boolean' },
|
|
362
|
+
createdAt: { type: 'string' },
|
|
363
|
+
modifiedAt: { type: 'string' },
|
|
364
|
+
},
|
|
365
|
+
required: ['path', 'type', 'size', 'isFile', 'isDirectory', 'createdAt', 'modifiedAt'],
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
name: 'create_directory',
|
|
370
|
+
annotations: WRITE_ANNOTATIONS,
|
|
371
|
+
description: 'Create a directory (recursively). Succeeds if it already exists. Returns { path, created }.',
|
|
372
|
+
inputSchema: {
|
|
373
|
+
type: 'object',
|
|
374
|
+
properties: { path: pathProp },
|
|
375
|
+
required: ['path'],
|
|
376
|
+
},
|
|
377
|
+
outputSchema: {
|
|
378
|
+
type: 'object',
|
|
379
|
+
properties: { path: { type: 'string' }, created: { type: 'boolean' } },
|
|
380
|
+
required: ['path', 'created'],
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
name: 'move',
|
|
385
|
+
annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
|
|
386
|
+
description: 'Move or rename a file/directory from "source" to "destination". Returns { source, destination }.',
|
|
387
|
+
inputSchema: {
|
|
388
|
+
type: 'object',
|
|
389
|
+
properties: {
|
|
390
|
+
source: { type: 'string', description: 'Existing path to move.' },
|
|
391
|
+
destination: { type: 'string', description: 'Target path.' },
|
|
392
|
+
},
|
|
393
|
+
required: ['source', 'destination'],
|
|
394
|
+
},
|
|
395
|
+
outputSchema: {
|
|
396
|
+
type: 'object',
|
|
397
|
+
properties: { source: { type: 'string' }, destination: { type: 'string' } },
|
|
398
|
+
required: ['source', 'destination'],
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
name: 'delete',
|
|
403
|
+
annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
|
|
404
|
+
description: 'Delete a file or directory. Pass "recursive": true to remove a non-empty directory. Returns { path, deleted }.',
|
|
405
|
+
inputSchema: {
|
|
406
|
+
type: 'object',
|
|
407
|
+
properties: {
|
|
408
|
+
path: pathProp,
|
|
409
|
+
recursive: { type: 'boolean', description: 'Remove directories and their contents recursively.' },
|
|
410
|
+
},
|
|
411
|
+
required: ['path'],
|
|
412
|
+
},
|
|
413
|
+
outputSchema: {
|
|
414
|
+
type: 'object',
|
|
415
|
+
properties: { path: { type: 'string' }, deleted: { type: 'boolean' } },
|
|
416
|
+
required: ['path', 'deleted'],
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
name: 'get_allowed_directories',
|
|
421
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
422
|
+
description: 'Returns the list of directories that this filesystem MCP server is ' +
|
|
423
|
+
'currently allowed to access. The list is the merged result of ' +
|
|
424
|
+
'server-level roots (configured via FLUJO MCP settings), MCP-node-level ' +
|
|
425
|
+
'roots (set in the FlowBuilder), and the FLUJO_FS_ROOTS environment ' +
|
|
426
|
+
'variable ceiling. When no roots are configured at all, returns the FLUJO ' +
|
|
427
|
+
'data directory as the default (so the file browser is usable out of the box).',
|
|
428
|
+
inputSchema: {
|
|
429
|
+
type: 'object',
|
|
430
|
+
properties: {},
|
|
431
|
+
required: [],
|
|
432
|
+
},
|
|
433
|
+
outputSchema: {
|
|
434
|
+
type: 'object',
|
|
435
|
+
properties: {
|
|
436
|
+
directories: {
|
|
437
|
+
type: 'array',
|
|
438
|
+
items: { type: 'string' },
|
|
439
|
+
description: 'Absolute paths of all allowed directories.',
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
required: ['directories'],
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
];
|
|
446
|
+
}
|
|
447
|
+
function splitLines(content) {
|
|
448
|
+
// Keep it simple + cross-platform: normalize CRLF/CR to LF for counting.
|
|
449
|
+
return content.replace(/\r\n?/g, '\n').split('\n');
|
|
450
|
+
}
|
|
451
|
+
/** Detect the dominant line ending of an existing file so writes stay consistent. */
|
|
452
|
+
function detectEol(content) {
|
|
453
|
+
return content.includes('\r\n') ? '\r\n' : '\n';
|
|
454
|
+
}
|
|
455
|
+
/** UTF-8 byte-order-mark character (U+FEFF) as it decodes into a JS string. */
|
|
456
|
+
const UTF8_BOM = '';
|
|
457
|
+
/**
|
|
458
|
+
* #254 (TOCTOU guard): SHA-256 (hex) of the RAW string as read from disk
|
|
459
|
+
* (before any CRLF/BOM normalization). `read_file` returns this as `contentHash`;
|
|
460
|
+
* the mutating tools accept it back as an optional `expectedHash` and refuse to
|
|
461
|
+
* write when the freshly-read file no longer hashes to it — catching the case
|
|
462
|
+
* where the file changed on disk during the (arbitrarily long) approval window.
|
|
463
|
+
*/
|
|
464
|
+
function contentHash(content) {
|
|
465
|
+
return createHash('sha256').update(content, 'utf8').digest('hex');
|
|
466
|
+
}
|
|
467
|
+
/** Split a leading UTF-8 BOM off `content` so it can be re-attached after edit. */
|
|
468
|
+
function splitBom(content) {
|
|
469
|
+
return content.startsWith(UTF8_BOM)
|
|
470
|
+
? { bom: UTF8_BOM, body: content.slice(UTF8_BOM.length) }
|
|
471
|
+
: { bom: '', body: content };
|
|
472
|
+
}
|
|
473
|
+
/** Standard actionable message when the on-disk file changed after approval. */
|
|
474
|
+
const STALE_CONTENT_MESSAGE = 'File changed after permission approval. Read it again before editing. No changes written.';
|
|
475
|
+
/** Heuristic binary sniff: a NUL byte in the first chunk means "not text". */
|
|
476
|
+
function looksBinary(buf) {
|
|
477
|
+
const n = Math.min(buf.length, 8000);
|
|
478
|
+
for (let i = 0; i < n; i++)
|
|
479
|
+
if (buf[i] === 0)
|
|
480
|
+
return true;
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Stream a file line-by-line and collect the lines that contain `needle`
|
|
485
|
+
* (case-insensitive substring). Streaming means a match can be found without
|
|
486
|
+
* buffering the whole file. Stops early once `max` matches are collected.
|
|
487
|
+
*/
|
|
488
|
+
async function grepFileLines(filePath, needle, max) {
|
|
489
|
+
const lower = needle.toLowerCase();
|
|
490
|
+
const matches = [];
|
|
491
|
+
let truncated = false;
|
|
492
|
+
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
|
493
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
494
|
+
let lineNo = 0;
|
|
495
|
+
try {
|
|
496
|
+
for await (const line of rl) {
|
|
497
|
+
lineNo++;
|
|
498
|
+
if (line.toLowerCase().includes(lower)) {
|
|
499
|
+
matches.push({ line: lineNo, text: line.slice(0, 400) });
|
|
500
|
+
if (matches.length >= max) {
|
|
501
|
+
truncated = true;
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
finally {
|
|
508
|
+
rl.close();
|
|
509
|
+
stream.destroy();
|
|
510
|
+
}
|
|
511
|
+
return { matches, truncated };
|
|
512
|
+
}
|
|
513
|
+
async function readSingleFileTool(args, roots) {
|
|
514
|
+
const filePath = await resolvePath(args.path, roots);
|
|
515
|
+
const hasRange = typeof args.from === 'number' || typeof args.to === 'number';
|
|
516
|
+
const pattern = typeof args.pattern === 'string' ? args.pattern.trim() : '';
|
|
517
|
+
// #287: guard whole-file reads of large files. Stat first so we never buffer a
|
|
518
|
+
// huge file just to reject it. An explicit line range or a `pattern` opts out.
|
|
519
|
+
let stat;
|
|
520
|
+
try {
|
|
521
|
+
stat = await fs.stat(filePath);
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
// fall through: fs.readFile below surfaces the real ENOENT/EACCES error.
|
|
525
|
+
}
|
|
526
|
+
if (stat && !stat.isFile()) {
|
|
527
|
+
throw new Error('Expected a regular file to read.');
|
|
528
|
+
}
|
|
529
|
+
const size = Number(stat?.size ?? 0);
|
|
530
|
+
if (size > LARGE_FILE_BYTES && !hasRange && !pattern) {
|
|
531
|
+
return errorResult(`File is large (${size} bytes > ${LARGE_FILE_BYTES} threshold). Reading it whole would flood the context. ` +
|
|
532
|
+
`Provide a "pattern" to grep for (matching lines + context are returned, then read targeted ranges with "from"/"to"), ` +
|
|
533
|
+
`or pass pattern "*" to read the whole file anyway.`);
|
|
534
|
+
}
|
|
535
|
+
// #287: pattern grep path (only when no explicit range was given). `*` is the
|
|
536
|
+
// escape hatch that means "read the whole file regardless".
|
|
537
|
+
if (pattern && pattern !== '*' && !hasRange) {
|
|
538
|
+
const buf = await fs.readFile(filePath);
|
|
539
|
+
if (looksBinary(buf)) {
|
|
540
|
+
return errorResult(`File appears to be binary; cannot grep for a pattern: ${filePath}`);
|
|
541
|
+
}
|
|
542
|
+
const content = buf.toString('utf8');
|
|
543
|
+
const lines = splitLines(content);
|
|
544
|
+
const totalLines = lines.length;
|
|
545
|
+
const lower = pattern.toLowerCase();
|
|
546
|
+
const matches = [];
|
|
547
|
+
for (let i = 0; i < lines.length; i++) {
|
|
548
|
+
if (lines[i].toLowerCase().includes(lower)) {
|
|
549
|
+
matches.push({ line: i + 1, text: lines[i].slice(0, 400) });
|
|
550
|
+
if (matches.length >= MAX_READ_PATTERN_MATCHES)
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const truncated = matches.length >= MAX_READ_PATTERN_MATCHES;
|
|
555
|
+
// Build a readable excerpt: matching lines with a small context window,
|
|
556
|
+
// line-numbered, with `…` separators between non-adjacent regions.
|
|
557
|
+
const show = new Set();
|
|
558
|
+
for (const m of matches) {
|
|
559
|
+
for (let l = m.line - READ_PATTERN_CONTEXT; l <= m.line + READ_PATTERN_CONTEXT; l++) {
|
|
560
|
+
if (l >= 1 && l <= totalLines)
|
|
561
|
+
show.add(l);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const ordered = [...show].sort((a, b) => a - b);
|
|
565
|
+
const parts = [];
|
|
566
|
+
let prev = 0;
|
|
567
|
+
for (const l of ordered) {
|
|
568
|
+
if (prev && l > prev + 1)
|
|
569
|
+
parts.push('…');
|
|
570
|
+
parts.push(`${l}: ${lines[l - 1]}`);
|
|
571
|
+
prev = l;
|
|
572
|
+
}
|
|
573
|
+
let out = parts.join('\n');
|
|
574
|
+
if (out.length > MAX_READ_CHARS)
|
|
575
|
+
out = out.slice(0, MAX_READ_CHARS) + '\n…[truncated]';
|
|
576
|
+
recordTouchedFile(filePath, 'read', size);
|
|
577
|
+
return dualResult({
|
|
578
|
+
path: filePath,
|
|
579
|
+
from: 1,
|
|
580
|
+
to: totalLines,
|
|
581
|
+
totalLines,
|
|
582
|
+
truncated,
|
|
583
|
+
content: matches.length ? out : `(no lines matched pattern ${JSON.stringify(pattern)})`,
|
|
584
|
+
matches,
|
|
585
|
+
contentHash: contentHash(content),
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
// Whole-file / explicit-range read (pattern '*' lands here too).
|
|
589
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
590
|
+
const lines = splitLines(content);
|
|
591
|
+
const totalLines = lines.length;
|
|
592
|
+
let from = typeof args.from === 'number' ? Math.max(1, Math.floor(args.from)) : 1;
|
|
593
|
+
let to = typeof args.to === 'number' ? Math.floor(args.to) : totalLines;
|
|
594
|
+
if (to < from)
|
|
595
|
+
[from, to] = [to, from];
|
|
596
|
+
to = Math.min(to, totalLines);
|
|
597
|
+
let out = hasRange ? lines.slice(from - 1, to).join('\n') : content;
|
|
598
|
+
let truncated = false;
|
|
599
|
+
if (out.length > MAX_READ_CHARS) {
|
|
600
|
+
out = out.slice(0, MAX_READ_CHARS) + '\n…[truncated]';
|
|
601
|
+
truncated = true;
|
|
602
|
+
}
|
|
603
|
+
recordTouchedFile(filePath, 'read', size);
|
|
604
|
+
return dualResult({ path: filePath, from: hasRange ? from : 1, to: hasRange ? to : totalLines, totalLines, truncated, content: out, contentHash: contentHash(content) });
|
|
605
|
+
}
|
|
606
|
+
function resultPayload(result) {
|
|
607
|
+
const structured = result.structuredContent;
|
|
608
|
+
if (structured)
|
|
609
|
+
return structured;
|
|
610
|
+
const text = result.content.find((item) => item.type === 'text');
|
|
611
|
+
if (!text || text.type !== 'text')
|
|
612
|
+
return { error: 'File read failed.' };
|
|
613
|
+
try {
|
|
614
|
+
const parsed = JSON.parse(text.text);
|
|
615
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
616
|
+
? parsed
|
|
617
|
+
: { error: text.text };
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
return { error: text.text };
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
async function readFileTool(args, roots) {
|
|
624
|
+
const hasPath = Object.prototype.hasOwnProperty.call(args, 'path');
|
|
625
|
+
const hasPaths = Object.prototype.hasOwnProperty.call(args, 'paths');
|
|
626
|
+
if (hasPath && hasPaths)
|
|
627
|
+
throw new Error('Provide either "path" or "paths", not both.');
|
|
628
|
+
if (!hasPaths)
|
|
629
|
+
return await readSingleFileTool(args, roots);
|
|
630
|
+
if (!Array.isArray(args.paths) || args.paths.length === 0) {
|
|
631
|
+
throw new Error('Provide a non-empty "paths" array.');
|
|
632
|
+
}
|
|
633
|
+
if (args.paths.length > MAX_BATCH_READ_FILES) {
|
|
634
|
+
throw new Error(`Provide at most ${MAX_BATCH_READ_FILES} paths.`);
|
|
635
|
+
}
|
|
636
|
+
if (args.paths.some((item) => typeof item !== 'string' || !item.trim())) {
|
|
637
|
+
throw new Error('Every entry in "paths" must be a non-empty string.');
|
|
638
|
+
}
|
|
639
|
+
const files = [];
|
|
640
|
+
for (const requestedPath of args.paths) {
|
|
641
|
+
let resolvedPath;
|
|
642
|
+
let record;
|
|
643
|
+
try {
|
|
644
|
+
resolvedPath = await resolvePath(requestedPath, roots);
|
|
645
|
+
const result = await readSingleFileTool({ ...args, path: requestedPath, paths: undefined }, roots);
|
|
646
|
+
const payload = resultPayload(result);
|
|
647
|
+
record = result.isError
|
|
648
|
+
? { requestedPath, path: resolvedPath, error: String(payload.error ?? 'File read failed.') }
|
|
649
|
+
: payload;
|
|
650
|
+
}
|
|
651
|
+
catch (err) {
|
|
652
|
+
record = {
|
|
653
|
+
requestedPath,
|
|
654
|
+
...(resolvedPath ? { path: resolvedPath } : {}),
|
|
655
|
+
error: err instanceof Error ? err.message : String(err),
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
const candidate = { files: [...files, record] };
|
|
659
|
+
if (JSON.stringify(candidate).length > MAX_BATCH_READ_CHARS) {
|
|
660
|
+
record = {
|
|
661
|
+
requestedPath,
|
|
662
|
+
...(resolvedPath ? { path: resolvedPath } : {}),
|
|
663
|
+
error: `Batch response limit of ${MAX_BATCH_READ_CHARS} serialized characters exceeded.`,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
files.push(record);
|
|
667
|
+
}
|
|
668
|
+
return dualResult({ files });
|
|
669
|
+
}
|
|
670
|
+
async function writeFileTool(args, roots) {
|
|
671
|
+
const filePath = await resolvePath(args.path, roots);
|
|
672
|
+
const content = typeof args.content === 'string' ? args.content : '';
|
|
673
|
+
const mode = args.mode === 'append' || args.mode === 'insert' ? args.mode : 'overwrite';
|
|
674
|
+
const hasRange = typeof args.startLine === 'number' || typeof args.endLine === 'number';
|
|
675
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
676
|
+
recordTouchedFile(filePath, 'write');
|
|
677
|
+
// Whole-file overwrite (default, backward-compatible).
|
|
678
|
+
if (mode === 'overwrite' && !hasRange) {
|
|
679
|
+
await fs.writeFile(filePath, content, 'utf8');
|
|
680
|
+
return dualResult({ path: filePath, bytesWritten: Buffer.byteLength(content, 'utf8'), mode: 'overwrite' });
|
|
681
|
+
}
|
|
682
|
+
// The remaining modes operate relative to the existing file (empty if absent).
|
|
683
|
+
let existing = '';
|
|
684
|
+
try {
|
|
685
|
+
existing = await fs.readFile(filePath, 'utf8');
|
|
686
|
+
}
|
|
687
|
+
catch {
|
|
688
|
+
existing = '';
|
|
689
|
+
}
|
|
690
|
+
// #254 TOCTOU guard: for the read-modify-write modes (append/insert/range-
|
|
691
|
+
// overwrite) honor an optional expectedHash (the contentHash from read_file)
|
|
692
|
+
// and refuse to write when the file changed on disk since it was read.
|
|
693
|
+
const expectedHash = typeof args.expectedHash === 'string' ? args.expectedHash : undefined;
|
|
694
|
+
if (expectedHash && contentHash(existing) !== expectedHash) {
|
|
695
|
+
return errorResult(STALE_CONTENT_MESSAGE);
|
|
696
|
+
}
|
|
697
|
+
// #254 BOM preservation: operate on the body and re-attach a leading UTF-8 BOM
|
|
698
|
+
// (if the file had one) on write so the read-modify-write modes never drop it.
|
|
699
|
+
const { bom, body: existingBody } = splitBom(existing);
|
|
700
|
+
if (mode === 'append') {
|
|
701
|
+
const sep = existingBody.length && !existingBody.endsWith('\n') && !existingBody.endsWith('\r\n') ? detectEol(existingBody) : '';
|
|
702
|
+
const next = bom + existingBody + sep + content;
|
|
703
|
+
await fs.writeFile(filePath, next, 'utf8');
|
|
704
|
+
return dualResult({ path: filePath, bytesWritten: Buffer.byteLength(next, 'utf8'), mode: 'append' });
|
|
705
|
+
}
|
|
706
|
+
const eol = detectEol(existingBody);
|
|
707
|
+
const lines = existingBody.length ? existingBody.split(/\r?\n/) : [];
|
|
708
|
+
const insertLines = content.split(/\r?\n/);
|
|
709
|
+
const total = lines.length;
|
|
710
|
+
if (mode === 'insert') {
|
|
711
|
+
const at = typeof args.startLine === 'number' ? Math.max(1, Math.floor(args.startLine)) : total + 1;
|
|
712
|
+
const idx = Math.min(at - 1, total);
|
|
713
|
+
lines.splice(idx, 0, ...insertLines);
|
|
714
|
+
const next = bom + lines.join(eol);
|
|
715
|
+
await fs.writeFile(filePath, next, 'utf8');
|
|
716
|
+
return dualResult({ path: filePath, bytesWritten: Buffer.byteLength(next, 'utf8'), mode: 'insert', startLine: at, linesInserted: insertLines.length });
|
|
717
|
+
}
|
|
718
|
+
// overwrite a specific line range
|
|
719
|
+
let start = typeof args.startLine === 'number' ? Math.max(1, Math.floor(args.startLine)) : 1;
|
|
720
|
+
let end = typeof args.endLine === 'number' ? Math.floor(args.endLine) : start;
|
|
721
|
+
if (end < start)
|
|
722
|
+
[start, end] = [end, start];
|
|
723
|
+
end = Math.min(end, total);
|
|
724
|
+
const linesReplaced = Math.max(0, end - start + 1);
|
|
725
|
+
lines.splice(start - 1, linesReplaced, ...insertLines);
|
|
726
|
+
const next = bom + lines.join(eol);
|
|
727
|
+
await fs.writeFile(filePath, next, 'utf8');
|
|
728
|
+
return dualResult({ path: filePath, bytesWritten: Buffer.byteLength(next, 'utf8'), mode: 'overwrite', startLine: start, endLine: end, linesReplaced });
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Char offsets [lo, hi) of the 1-based inclusive line range [start, end] within
|
|
732
|
+
* `text`. When both bounds are absent the whole string is returned. Offsets are
|
|
733
|
+
* computed by counting `\n`. PRECONDITION: `text` must be LF-normalized (the
|
|
734
|
+
* caller in `editFileTool` normalizes CR/CRLF to LF before matching), so every
|
|
735
|
+
* newline is exactly one character and the `+ 1` advance stays byte-exact.
|
|
736
|
+
*/
|
|
737
|
+
function regionOffsets(text, start, end) {
|
|
738
|
+
if (start === undefined && end === undefined)
|
|
739
|
+
return { lo: 0, hi: text.length };
|
|
740
|
+
const lines = text.split('\n');
|
|
741
|
+
const s = Math.max(1, Math.floor(start ?? 1));
|
|
742
|
+
const e = Math.min(lines.length, Math.floor(end ?? lines.length));
|
|
743
|
+
let lo = 0;
|
|
744
|
+
for (let k = 0; k < s - 1 && k < lines.length; k++)
|
|
745
|
+
lo += lines[k].length + 1;
|
|
746
|
+
let hi = lo;
|
|
747
|
+
for (let k = s - 1; k < e && k < lines.length; k++)
|
|
748
|
+
hi += lines[k].length + (k < lines.length - 1 ? 1 : 0);
|
|
749
|
+
return { lo, hi: Math.max(lo, hi) };
|
|
750
|
+
}
|
|
751
|
+
async function editFileTool(args, roots) {
|
|
752
|
+
const filePath = await resolvePath(args.path, roots);
|
|
753
|
+
const hasDiff = typeof args.diff === 'string' && args.diff.trim().length > 0;
|
|
754
|
+
const hasEdits = Array.isArray(args.edits) && args.edits.length > 0;
|
|
755
|
+
if (hasDiff && hasEdits) {
|
|
756
|
+
return errorResult('Provide either "diff" or "edits", not both.');
|
|
757
|
+
}
|
|
758
|
+
const original = await fs.readFile(filePath, 'utf8');
|
|
759
|
+
// #254 TOCTOU guard: if the caller passed the contentHash it saw at read time
|
|
760
|
+
// and the file has since changed on disk (e.g. during the approval window),
|
|
761
|
+
// refuse to write against the new content rather than silently clobber it.
|
|
762
|
+
const expectedHash = typeof args.expectedHash === 'string' ? args.expectedHash : undefined;
|
|
763
|
+
if (expectedHash && contentHash(original) !== expectedHash) {
|
|
764
|
+
return errorResult(STALE_CONTENT_MESSAGE);
|
|
765
|
+
}
|
|
766
|
+
// #254 BOM preservation: strip a leading UTF-8 BOM before matching/normalizing
|
|
767
|
+
// (so oldText/diff context matches the real body) and re-attach it on write.
|
|
768
|
+
const { bom, body } = splitBom(original);
|
|
769
|
+
// (2) Unified-diff apply — atomic.
|
|
770
|
+
if (hasDiff) {
|
|
771
|
+
// Match in LF space so a CRLF file's trailing \r doesn't make every context
|
|
772
|
+
// line mismatch the (\r-stripped) diff body; restore the original EOL on
|
|
773
|
+
// write. The literal-edits path already did this (#187); the diff path did
|
|
774
|
+
// not, which meant diff apply could never succeed on a CRLF file.
|
|
775
|
+
const diffEol = detectEol(body);
|
|
776
|
+
const normalized = body.replace(/\r\n?/g, '\n');
|
|
777
|
+
let out;
|
|
778
|
+
try {
|
|
779
|
+
out = applyUnifiedDiff(normalized, args.diff);
|
|
780
|
+
}
|
|
781
|
+
catch (err) {
|
|
782
|
+
return errorResult(`Diff apply failed: ${err instanceof Error ? err.message : String(err)}. No changes written.`);
|
|
783
|
+
}
|
|
784
|
+
const finalContent = bom + (diffEol === '\r\n' ? out.result.replace(/\n/g, '\r\n') : out.result);
|
|
785
|
+
await fs.writeFile(filePath, finalContent, 'utf8');
|
|
786
|
+
recordTouchedFile(filePath, 'write');
|
|
787
|
+
return dualResult({ path: filePath, applied: true, mode: 'diff', diff: { added: out.added, removed: out.removed } });
|
|
788
|
+
}
|
|
789
|
+
// (1) Literal find/replace edits.
|
|
790
|
+
const edits = Array.isArray(args.edits) ? args.edits : [];
|
|
791
|
+
if (edits.length === 0) {
|
|
792
|
+
return errorResult('Provide a non-empty "edits" array of { oldText, newText } or a "diff" string.');
|
|
793
|
+
}
|
|
794
|
+
const gStart = typeof args.startLine === 'number' ? args.startLine : undefined;
|
|
795
|
+
const gEnd = typeof args.endLine === 'number' ? args.endLine : undefined;
|
|
796
|
+
// Match/apply in LF space so CR/CRLF differences between the file on disk and
|
|
797
|
+
// the model-supplied oldText don't produce spurious "not found" errors (#187).
|
|
798
|
+
// The file's original EOL is detected here and restored on write below.
|
|
799
|
+
const eol = detectEol(body);
|
|
800
|
+
let working = body.replace(/\r\n?/g, '\n');
|
|
801
|
+
let applied = 0;
|
|
802
|
+
for (const raw of edits) {
|
|
803
|
+
const edit = raw;
|
|
804
|
+
const oldText = typeof edit.oldText === 'string' ? edit.oldText.replace(/\r\n?/g, '\n') : '';
|
|
805
|
+
const newText = typeof edit.newText === 'string' ? edit.newText.replace(/\r\n?/g, '\n') : '';
|
|
806
|
+
if (!oldText)
|
|
807
|
+
return errorResult(`Edit #${applied + 1} has an empty "oldText".`);
|
|
808
|
+
const start = typeof edit.startLine === 'number' ? edit.startLine : gStart;
|
|
809
|
+
const end = typeof edit.endLine === 'number' ? edit.endLine : gEnd;
|
|
810
|
+
const scoped = start !== undefined || end !== undefined;
|
|
811
|
+
// Collect every occurrence in LF space, then resolve which one to edit.
|
|
812
|
+
const occurrences = [];
|
|
813
|
+
for (let p = working.indexOf(oldText); p !== -1; p = working.indexOf(oldText, p + 1)) {
|
|
814
|
+
occurrences.push(p);
|
|
815
|
+
}
|
|
816
|
+
if (occurrences.length === 0) {
|
|
817
|
+
return errorResult(`Edit #${applied + 1}: "oldText" not found in ${filePath}. No changes written.`);
|
|
818
|
+
}
|
|
819
|
+
// startLine/endLine are a disambiguation HINT, not a hard gate: if exactly
|
|
820
|
+
// one occurrence STARTS within the hinted range, use it (this also tolerates
|
|
821
|
+
// a multi-line match that extends past endLine). Otherwise fall back to a
|
|
822
|
+
// whole-file unambiguous match so a slightly-off line estimate never blocks
|
|
823
|
+
// an edit that is otherwise unique (#170 follow-up).
|
|
824
|
+
let idx = -1;
|
|
825
|
+
if (scoped) {
|
|
826
|
+
const { lo, hi } = regionOffsets(working, start, end);
|
|
827
|
+
const inRange = occurrences.filter((p) => p >= lo && p < hi);
|
|
828
|
+
if (inRange.length === 1)
|
|
829
|
+
idx = inRange[0];
|
|
830
|
+
}
|
|
831
|
+
if (idx === -1) {
|
|
832
|
+
if (occurrences.length === 1) {
|
|
833
|
+
idx = occurrences[0];
|
|
834
|
+
}
|
|
835
|
+
else {
|
|
836
|
+
return errorResult(`Edit #${applied + 1}: "oldText" is ambiguous (appears ${occurrences.length} times); add more surrounding context or a tighter startLine/endLine so exactly one occurrence is in range. No changes written.`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
working = working.slice(0, idx) + newText + working.slice(idx + oldText.length);
|
|
840
|
+
applied += 1;
|
|
841
|
+
}
|
|
842
|
+
const diff = buildLineDiff(body, working);
|
|
843
|
+
// Restore the file's original line-ending style so unchanged lines keep their
|
|
844
|
+
// bytes and we don't rewrite the whole file just because EOLs differ (#187).
|
|
845
|
+
// Re-attach any leading UTF-8 BOM that was stripped before matching (#254).
|
|
846
|
+
const finalContent = bom + (eol === '\r\n' ? working.replace(/\n/g, '\r\n') : working);
|
|
847
|
+
await fs.writeFile(filePath, finalContent, 'utf8');
|
|
848
|
+
recordTouchedFile(filePath, 'write');
|
|
849
|
+
return dualResult({ path: filePath, mode: 'edits', editsApplied: applied, diff });
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Locate where a hunk's "old side" (its context + removed lines, in order)
|
|
853
|
+
* occurs in `origLines`. The unified-diff header's line number is treated as a
|
|
854
|
+
* HINT, not gospel: we prefer a match at the declared position but search
|
|
855
|
+
* outward from it so a stale/estimated line number still applies cleanly (the
|
|
856
|
+
* "fuzz" that GNU patch / `git apply` provide). `minPos` forbids matching before
|
|
857
|
+
* already-consumed lines. A pure-insertion hunk (empty old side) anchors at the
|
|
858
|
+
* hint. Throws when the context genuinely doesn't exist anywhere in the file.
|
|
859
|
+
*/
|
|
860
|
+
function locateHunk(origLines, oldBlock, hint, minPos) {
|
|
861
|
+
if (oldBlock.length === 0) {
|
|
862
|
+
return Math.min(Math.max(hint, minPos), origLines.length);
|
|
863
|
+
}
|
|
864
|
+
const maxStart = origLines.length - oldBlock.length;
|
|
865
|
+
const matchesAt = (start) => {
|
|
866
|
+
if (start < minPos || start > maxStart)
|
|
867
|
+
return false;
|
|
868
|
+
for (let k = 0; k < oldBlock.length; k++) {
|
|
869
|
+
if (origLines[start + k] !== oldBlock[k])
|
|
870
|
+
return false;
|
|
871
|
+
}
|
|
872
|
+
return true;
|
|
873
|
+
};
|
|
874
|
+
const clampedHint = Math.max(minPos, hint);
|
|
875
|
+
if (matchesAt(clampedHint))
|
|
876
|
+
return clampedHint;
|
|
877
|
+
const radius = Math.max(clampedHint - minPos, maxStart - clampedHint);
|
|
878
|
+
for (let r = 1; r <= radius; r++) {
|
|
879
|
+
if (matchesAt(clampedHint - r))
|
|
880
|
+
return clampedHint - r;
|
|
881
|
+
if (matchesAt(clampedHint + r))
|
|
882
|
+
return clampedHint + r;
|
|
883
|
+
}
|
|
884
|
+
throw new Error(`could not locate hunk near line ${hint + 1}; its context/removed lines do not match the file. First expected line: ${JSON.stringify(oldBlock[0])}`);
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Minimal, dependency-free unified-diff applier (issue #170 D1). Parses standard
|
|
888
|
+
* `@@ -oldStart,oldLen +newStart,newLen @@` hunks and applies them atomically:
|
|
889
|
+
* each hunk is RELOCATED to where its context (' ') + removed ('-') lines
|
|
890
|
+
* actually match the original (see locateHunk) rather than trusting the header's
|
|
891
|
+
* line number, so a slightly-off line number no longer hard-fails. Added ('+')
|
|
892
|
+
* lines are inserted; "" markers are ignored. Throws
|
|
893
|
+
* with no partial write when a hunk's context can't be found at all.
|
|
894
|
+
* PRECONDITION: `original` is LF-normalized by the caller.
|
|
895
|
+
*/
|
|
896
|
+
function applyUnifiedDiff(original, diffText) {
|
|
897
|
+
const origLines = original.split('\n');
|
|
898
|
+
const diffLines = diffText.split(/\r?\n/);
|
|
899
|
+
const hunkHeader = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
900
|
+
// Parse the diff into hunks first so relocation can reason about a whole hunk.
|
|
901
|
+
const hunks = [];
|
|
902
|
+
let cur = null;
|
|
903
|
+
for (let i = 0; i < diffLines.length; i++) {
|
|
904
|
+
const line = diffLines[i];
|
|
905
|
+
if (line === undefined)
|
|
906
|
+
break;
|
|
907
|
+
const m = hunkHeader.exec(line);
|
|
908
|
+
if (m) {
|
|
909
|
+
cur = { oldStart: parseInt(m[1], 10), ops: [] };
|
|
910
|
+
hunks.push(cur);
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
if (line.startsWith('--- ') || line.startsWith('+++ ')) {
|
|
914
|
+
cur = null; // file header — leave the current hunk (if any)
|
|
915
|
+
continue;
|
|
916
|
+
}
|
|
917
|
+
if (!cur)
|
|
918
|
+
continue; // preamble before the first hunk
|
|
919
|
+
const tag = line.length ? line[0] : ' ';
|
|
920
|
+
const body = line.length ? line.slice(1) : '';
|
|
921
|
+
if (tag === ' ' || tag === '-' || tag === '+') {
|
|
922
|
+
cur.ops.push({ tag, body });
|
|
923
|
+
}
|
|
924
|
+
else if (tag === '\\') {
|
|
925
|
+
// "" — nothing to apply.
|
|
926
|
+
}
|
|
927
|
+
else {
|
|
928
|
+
throw new Error(`unexpected diff line: ${JSON.stringify(line)}`);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
if (hunks.length === 0)
|
|
932
|
+
throw new Error('no unified-diff hunks (@@ ... @@) found');
|
|
933
|
+
const out = [];
|
|
934
|
+
let cursor = 0; // 0-based index into origLines already consumed/emitted
|
|
935
|
+
let added = 0;
|
|
936
|
+
let removed = 0;
|
|
937
|
+
for (const hunk of hunks) {
|
|
938
|
+
const oldBlock = hunk.ops.filter((o) => o.tag === ' ' || o.tag === '-').map((o) => o.body);
|
|
939
|
+
const pos = locateHunk(origLines, oldBlock, Math.max(0, hunk.oldStart - 1), cursor);
|
|
940
|
+
if (pos < cursor)
|
|
941
|
+
throw new Error('overlapping or out-of-order hunks');
|
|
942
|
+
while (cursor < pos) {
|
|
943
|
+
out.push(origLines[cursor]);
|
|
944
|
+
cursor++;
|
|
945
|
+
}
|
|
946
|
+
for (const op of hunk.ops) {
|
|
947
|
+
if (op.tag === ' ') {
|
|
948
|
+
out.push(origLines[cursor]);
|
|
949
|
+
cursor++;
|
|
950
|
+
}
|
|
951
|
+
else if (op.tag === '-') {
|
|
952
|
+
cursor++;
|
|
953
|
+
removed++;
|
|
954
|
+
}
|
|
955
|
+
else {
|
|
956
|
+
out.push(op.body);
|
|
957
|
+
added++;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
while (cursor < origLines.length) {
|
|
962
|
+
out.push(origLines[cursor]);
|
|
963
|
+
cursor++;
|
|
964
|
+
}
|
|
965
|
+
return { result: out.join('\n'), added, removed };
|
|
966
|
+
}
|
|
967
|
+
/** Minimal line-level diff summary (added/removed counts). */
|
|
968
|
+
function buildLineDiff(before, after) {
|
|
969
|
+
const a = splitLines(before);
|
|
970
|
+
const b = splitLines(after);
|
|
971
|
+
const bSet = new Map();
|
|
972
|
+
for (const line of b)
|
|
973
|
+
bSet.set(line, (bSet.get(line) ?? 0) + 1);
|
|
974
|
+
const aSet = new Map();
|
|
975
|
+
for (const line of a)
|
|
976
|
+
aSet.set(line, (aSet.get(line) ?? 0) + 1);
|
|
977
|
+
let removed = 0;
|
|
978
|
+
for (const [line, count] of aSet)
|
|
979
|
+
removed += Math.max(0, count - (bSet.get(line) ?? 0));
|
|
980
|
+
let added = 0;
|
|
981
|
+
for (const [line, count] of bSet)
|
|
982
|
+
added += Math.max(0, count - (aSet.get(line) ?? 0));
|
|
983
|
+
return { added, removed };
|
|
984
|
+
}
|
|
985
|
+
async function entryType(full) {
|
|
986
|
+
try {
|
|
987
|
+
const st = await fs.lstat(full);
|
|
988
|
+
if (st.isFile())
|
|
989
|
+
return 'file';
|
|
990
|
+
if (st.isDirectory())
|
|
991
|
+
return 'directory';
|
|
992
|
+
return 'other';
|
|
993
|
+
}
|
|
994
|
+
catch {
|
|
995
|
+
return 'other';
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
async function listDirTool(args, roots) {
|
|
999
|
+
const dirPath = await resolvePath(args.path, roots);
|
|
1000
|
+
const names = await fs.readdir(dirPath);
|
|
1001
|
+
const entries = await Promise.all(names.map(async (name) => {
|
|
1002
|
+
const full = path.join(dirPath, name);
|
|
1003
|
+
const type = await entryType(full);
|
|
1004
|
+
let size = 0;
|
|
1005
|
+
try {
|
|
1006
|
+
size = (await fs.stat(full)).size;
|
|
1007
|
+
}
|
|
1008
|
+
catch {
|
|
1009
|
+
/* ignore */
|
|
1010
|
+
}
|
|
1011
|
+
return { name, type, size };
|
|
1012
|
+
}));
|
|
1013
|
+
entries.sort((x, y) => (x.type === y.type ? x.name.localeCompare(y.name) : x.type === 'directory' ? -1 : 1));
|
|
1014
|
+
return dualResult({ path: dirPath, entries });
|
|
1015
|
+
}
|
|
1016
|
+
async function dirTreeTool(args, roots) {
|
|
1017
|
+
const rootPath = await resolvePath(args.path, roots);
|
|
1018
|
+
const depth = Math.min(typeof args.depth === 'number' ? Math.max(1, Math.floor(args.depth)) : DEFAULT_TREE_DEPTH, MAX_TREE_DEPTH);
|
|
1019
|
+
let count = 0;
|
|
1020
|
+
let truncated = false;
|
|
1021
|
+
async function walk(dir, level) {
|
|
1022
|
+
if (level > depth || truncated)
|
|
1023
|
+
return [];
|
|
1024
|
+
let names;
|
|
1025
|
+
try {
|
|
1026
|
+
names = await fs.readdir(dir);
|
|
1027
|
+
}
|
|
1028
|
+
catch {
|
|
1029
|
+
return [];
|
|
1030
|
+
}
|
|
1031
|
+
names.sort((a, b) => a.localeCompare(b));
|
|
1032
|
+
const nodes = [];
|
|
1033
|
+
for (const name of names) {
|
|
1034
|
+
if (++count > MAX_TREE_ENTRIES) {
|
|
1035
|
+
truncated = true;
|
|
1036
|
+
break;
|
|
1037
|
+
}
|
|
1038
|
+
const full = path.join(dir, name);
|
|
1039
|
+
const type = await entryType(full);
|
|
1040
|
+
const node = { name, type };
|
|
1041
|
+
if (type === 'directory' && level < depth) {
|
|
1042
|
+
node.children = await walk(full, level + 1);
|
|
1043
|
+
}
|
|
1044
|
+
nodes.push(node);
|
|
1045
|
+
}
|
|
1046
|
+
return nodes;
|
|
1047
|
+
}
|
|
1048
|
+
const tree = await walk(rootPath, 1);
|
|
1049
|
+
return dualResult({ path: rootPath, depth, truncated, tree });
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* #287: content-scan one file for `needle`. Skips oversized and likely-binary
|
|
1053
|
+
* files up front (a huge perf/robustness win over reading every file whole),
|
|
1054
|
+
* then streams it line-by-line so a match is found without buffering the file.
|
|
1055
|
+
*/
|
|
1056
|
+
async function scanFileContent(full, needle, remaining) {
|
|
1057
|
+
let st;
|
|
1058
|
+
try {
|
|
1059
|
+
st = await fs.stat(full);
|
|
1060
|
+
}
|
|
1061
|
+
catch {
|
|
1062
|
+
return [];
|
|
1063
|
+
}
|
|
1064
|
+
if (!st.isFile() || st.size > SEARCH_MAX_FILE_BYTES)
|
|
1065
|
+
return [];
|
|
1066
|
+
// Binary sniff on the first chunk without loading the whole file.
|
|
1067
|
+
try {
|
|
1068
|
+
const fh = await fs.open(full, 'r');
|
|
1069
|
+
try {
|
|
1070
|
+
const probe = Buffer.alloc(Math.min(8000, st.size));
|
|
1071
|
+
if (probe.length)
|
|
1072
|
+
await fh.read(probe, 0, probe.length, 0);
|
|
1073
|
+
if (looksBinary(probe))
|
|
1074
|
+
return [];
|
|
1075
|
+
}
|
|
1076
|
+
finally {
|
|
1077
|
+
await fh.close();
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
catch {
|
|
1081
|
+
return [];
|
|
1082
|
+
}
|
|
1083
|
+
const { matches } = await grepFileLines(full, needle, Math.max(1, remaining));
|
|
1084
|
+
return matches.map((m) => ({ path: full, line: m.line, text: m.text }));
|
|
1085
|
+
}
|
|
1086
|
+
async function searchTool(args, roots) {
|
|
1087
|
+
const rootPath = await resolvePath(args.path, roots);
|
|
1088
|
+
const namePattern = typeof args.namePattern === 'string' ? args.namePattern.toLowerCase() : '';
|
|
1089
|
+
const contentPattern = typeof args.content === 'string' ? args.content : '';
|
|
1090
|
+
if (!namePattern && !contentPattern) {
|
|
1091
|
+
return errorResult('Provide "namePattern" and/or "content" to search for.');
|
|
1092
|
+
}
|
|
1093
|
+
const matches = [];
|
|
1094
|
+
let truncated = false;
|
|
1095
|
+
// First pass: walk the tree collecting NAME matches immediately and gathering
|
|
1096
|
+
// the list of candidate files for a (possibly parallel) content scan.
|
|
1097
|
+
const files = [];
|
|
1098
|
+
async function walk(dir) {
|
|
1099
|
+
if (truncated)
|
|
1100
|
+
return;
|
|
1101
|
+
let names;
|
|
1102
|
+
try {
|
|
1103
|
+
names = await fs.readdir(dir);
|
|
1104
|
+
}
|
|
1105
|
+
catch {
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
for (const name of names) {
|
|
1109
|
+
if (truncated)
|
|
1110
|
+
return;
|
|
1111
|
+
const full = path.join(dir, name);
|
|
1112
|
+
const type = await entryType(full);
|
|
1113
|
+
if (namePattern && name.toLowerCase().includes(namePattern)) {
|
|
1114
|
+
matches.push({ path: full });
|
|
1115
|
+
if (matches.length >= MAX_SEARCH_RESULTS) {
|
|
1116
|
+
truncated = true;
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
if (type === 'directory') {
|
|
1121
|
+
await walk(full);
|
|
1122
|
+
}
|
|
1123
|
+
else if (type === 'file' && contentPattern) {
|
|
1124
|
+
files.push(full);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
await walk(rootPath);
|
|
1129
|
+
// Second pass: scan file contents with bounded concurrency and early stop.
|
|
1130
|
+
if (contentPattern && !truncated) {
|
|
1131
|
+
for (let i = 0; i < files.length && !truncated; i += SEARCH_CONCURRENCY) {
|
|
1132
|
+
const batch = files.slice(i, i + SEARCH_CONCURRENCY);
|
|
1133
|
+
const results = await Promise.all(batch.map((full) => scanFileContent(full, contentPattern, MAX_SEARCH_RESULTS)));
|
|
1134
|
+
for (const fileMatches of results) {
|
|
1135
|
+
for (const m of fileMatches) {
|
|
1136
|
+
matches.push(m);
|
|
1137
|
+
if (matches.length >= MAX_SEARCH_RESULTS) {
|
|
1138
|
+
truncated = true;
|
|
1139
|
+
break;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
if (truncated)
|
|
1143
|
+
break;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
return dualResult({ matches, truncated });
|
|
1148
|
+
}
|
|
1149
|
+
async function getFileInfoTool(args, roots) {
|
|
1150
|
+
const target = await resolvePath(args.path, roots);
|
|
1151
|
+
const st = await fs.stat(target);
|
|
1152
|
+
return dualResult({
|
|
1153
|
+
path: target,
|
|
1154
|
+
type: st.isFile() ? 'file' : st.isDirectory() ? 'directory' : 'other',
|
|
1155
|
+
size: st.size,
|
|
1156
|
+
isFile: st.isFile(),
|
|
1157
|
+
isDirectory: st.isDirectory(),
|
|
1158
|
+
createdAt: st.birthtime.toISOString(),
|
|
1159
|
+
modifiedAt: st.mtime.toISOString(),
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
async function createDirectoryTool(args, roots) {
|
|
1163
|
+
const dirPath = await resolvePath(args.path, roots);
|
|
1164
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
1165
|
+
return dualResult({ path: dirPath, created: true });
|
|
1166
|
+
}
|
|
1167
|
+
async function moveTool(args, roots) {
|
|
1168
|
+
const source = await resolvePath(args.source, roots);
|
|
1169
|
+
const destination = await resolvePath(args.destination, roots);
|
|
1170
|
+
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
1171
|
+
await fs.rename(source, destination);
|
|
1172
|
+
return dualResult({ source, destination });
|
|
1173
|
+
}
|
|
1174
|
+
async function deleteTool(args, roots) {
|
|
1175
|
+
const target = await resolvePath(args.path, roots);
|
|
1176
|
+
const recursive = args.recursive === true;
|
|
1177
|
+
await fs.rm(target, { recursive, force: false });
|
|
1178
|
+
return dualResult({ path: target, deleted: true });
|
|
1179
|
+
}
|
|
1180
|
+
async function getAllowedDirectoriesTool(roots) {
|
|
1181
|
+
return dualResult({ directories: roots });
|
|
1182
|
+
}
|
|
1183
|
+
export async function filesystemCallTool(toolName, args, callerNodeId) {
|
|
1184
|
+
try {
|
|
1185
|
+
// MCP App launcher (#97): pure UI trigger — returns immediately without
|
|
1186
|
+
// touching the filesystem. The app renders in chat; the user's pick returns
|
|
1187
|
+
// as a follow-up message. Handled before roots are resolved (it needs none).
|
|
1188
|
+
if (toolName === 'file_browser_ui') {
|
|
1189
|
+
return textResult('File browser shown to the user. Waiting for them to select a file — their choice will arrive as a follow-up message. Do not proceed until then.');
|
|
1190
|
+
}
|
|
1191
|
+
const roots = await loadEffectiveRoots(FILESYSTEM_SERVER_NAME, 'FLUJO_FS_ROOTS', callerNodeId);
|
|
1192
|
+
switch (toolName) {
|
|
1193
|
+
case 'read_file':
|
|
1194
|
+
return await readFileTool(args, roots);
|
|
1195
|
+
case 'write_file':
|
|
1196
|
+
return await writeFileTool(args, roots);
|
|
1197
|
+
case 'edit_file':
|
|
1198
|
+
return await editFileTool(args, roots);
|
|
1199
|
+
case 'list_dir':
|
|
1200
|
+
return await listDirTool(args, roots);
|
|
1201
|
+
case 'dir_tree':
|
|
1202
|
+
return await dirTreeTool(args, roots);
|
|
1203
|
+
case 'search':
|
|
1204
|
+
return await searchTool(args, roots);
|
|
1205
|
+
case 'get_file_info':
|
|
1206
|
+
return await getFileInfoTool(args, roots);
|
|
1207
|
+
case 'create_directory':
|
|
1208
|
+
return await createDirectoryTool(args, roots);
|
|
1209
|
+
case 'move':
|
|
1210
|
+
return await moveTool(args, roots);
|
|
1211
|
+
case 'delete':
|
|
1212
|
+
return await deleteTool(args, roots);
|
|
1213
|
+
case 'get_allowed_directories':
|
|
1214
|
+
return await getAllowedDirectoriesTool(roots);
|
|
1215
|
+
default:
|
|
1216
|
+
return errorResult(`Unknown tool on the built-in filesystem server: ${toolName}`);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
catch (err) {
|
|
1220
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1221
|
+
log.warn('filesystemCallTool failed', { toolName, err });
|
|
1222
|
+
// Do not attach structuredContent to an error. Every successful tool has
|
|
1223
|
+
// its own outputSchema, and `{ error }` does not conform to those schemas;
|
|
1224
|
+
// strict MCP clients validate it before the file-browser View can inspect
|
|
1225
|
+
// `isError`. The JSON text block remains machine-readable by payloadOf().
|
|
1226
|
+
return errorResult(msg);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
//# sourceMappingURL=tools.js.map
|