@shieldfive/mcp 0.2.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/src/format.mjs ADDED
@@ -0,0 +1,157 @@
1
+ // Presentation helpers.
2
+ //
3
+ // Every tool returns structured JSON as its payload. These exist so the
4
+ // human-readable summary line on top of that JSON is consistent, and so byte
5
+ // counts are never rendered by ad-hoc arithmetic at any of the 24 call sites.
6
+
7
+ const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
8
+
9
+ /** Decimal units, matching what Finder and Explorer show a user. */
10
+ export function formatBytes(bytes) {
11
+ if (!Number.isFinite(bytes) || bytes < 0) return 'unknown'
12
+ if (bytes < 1000) return `${bytes} B`
13
+
14
+ let value = bytes
15
+ let unit = 0
16
+ while (value >= 1000 && unit < UNITS.length - 1) {
17
+ value /= 1000
18
+ unit++
19
+ }
20
+
21
+ // Promote AFTER rounding, not before. The loop tests the raw quotient while
22
+ // the display rounds to zero decimals above 100, so 999,999 bytes rendered as
23
+ // "1000 KB" rather than "1.0 MB" -- and a duplicate report reading
24
+ // "1000 MB reclaimable" is a unit nobody uses.
25
+ const rounded = value >= 100 ? Math.round(value) : value
26
+ if (rounded >= 1000 && unit < UNITS.length - 1) {
27
+ value = rounded / 1000
28
+ unit++
29
+ }
30
+
31
+ return `${value >= 100 ? value.toFixed(0) : value.toFixed(1)} ${UNITS[unit]}`
32
+ }
33
+
34
+ /**
35
+ * A caller-supplied value, quoted for a message and cut to a readable length.
36
+ *
37
+ * A refusal that echoes its input whole puts all of it into the model's
38
+ * context: a 20,000-character path came back as a 20,139-character error, and
39
+ * nothing capped new_name at all. The schema bounds path arguments, but a
40
+ * handler can be reached without it, and new_name is not a path.
41
+ */
42
+ export function quote(value, max = 200) {
43
+ const text = String(value)
44
+ if (text.length <= max) return JSON.stringify(text)
45
+ return `${JSON.stringify(text.slice(0, max))}… (${text.length.toLocaleString('en-US')} characters in all)`
46
+ }
47
+
48
+ export function formatDate(mtimeMs) {
49
+ return new Date(mtimeMs).toISOString().slice(0, 10)
50
+ }
51
+
52
+ export function daysAgo(mtimeMs, now) {
53
+ return Math.floor((now - mtimeMs) / 86_400_000)
54
+ }
55
+
56
+ /**
57
+ * The MCP content payload for a tool result.
58
+ *
59
+ * A one-line summary first, then the JSON. The summary is what a model quotes
60
+ * back to the user, so it carries the caveat when there is one — a truncated
61
+ * scan says so on the line that gets read, not only in a field that may not be.
62
+ */
63
+ export function toolResult(summary, data) {
64
+ return {
65
+ content: [
66
+ { type: 'text', text: summary },
67
+ { type: 'text', text: JSON.stringify(data, null, 2) },
68
+ ],
69
+ }
70
+ }
71
+
72
+ /**
73
+ * A refusal, rendered so the model can act on it rather than retry blindly.
74
+ *
75
+ * A ToolError's `detail` -- what a partial failure had already moved, and the
76
+ * manifests that record it -- follows as a second block of JSON. It used to be
77
+ * dropped here, so the one structured account of a half-finished operation
78
+ * never reached the client.
79
+ */
80
+ export function toolFailure(err) {
81
+ const code = err?.code ?? 'error'
82
+ const content = [{ type: 'text', text: `[${code}] ${err?.message ?? String(err)}` }]
83
+ if (err?.detail !== undefined) {
84
+ content.push({ type: 'text', text: JSON.stringify(err.detail, null, 2) })
85
+ }
86
+ return { isError: true, content }
87
+ }
88
+
89
+ /**
90
+ * Warnings a scan produced, as sentences worth putting in front of a user.
91
+ *
92
+ * `notScanned` is the list walkRoots() returns of roots the file budget never
93
+ * reached. Returns [] when the scan was clean, so callers can join
94
+ * unconditionally.
95
+ */
96
+ export function scanWarnings(perRoot, notScanned = []) {
97
+ const warnings = []
98
+ const truncated = perRoot.filter((r) => r.truncated)
99
+ if (truncated.length || notScanned.length) {
100
+ const cap = (truncated[0] ?? perRoot[0])?.maxFiles
101
+ const where = []
102
+ if (truncated.length) where.push(`in ${truncated.map((r) => r.root).join(', ')}`)
103
+ if (notScanned.length) {
104
+ where.push(
105
+ `before reaching ${notScanned.join(', ')}, which ` +
106
+ `${notScanned.length === 1 ? 'was' : 'were'} not scanned at all`,
107
+ )
108
+ }
109
+ warnings.push(
110
+ `Scan stopped at the ${cap === undefined ? 'file' : `${cap.toLocaleString()}-file`} cap ` +
111
+ `${where.join(' and ')}. These results are PARTIAL — narrow the path or raise ` +
112
+ 'the cap before drawing a conclusion from them.',
113
+ )
114
+ }
115
+ const unreadable = perRoot.reduce((n, r) => n + r.unreadable.length, 0)
116
+ if (unreadable) {
117
+ warnings.push(
118
+ `${unreadable} item(s) could not be read (permissions or I/O) and are absent ` +
119
+ 'from these totals.',
120
+ )
121
+ }
122
+ const links = perRoot.reduce((n, r) => n + r.symlinksSkipped, 0)
123
+ if (links) {
124
+ warnings.push(`${links} symlink(s) skipped; this server never follows them.`)
125
+ }
126
+
127
+ // These were recorded by the walk and then dropped on the floor. A
128
+ // storage_summary that omits node_modules, dist and every dotfile can be
129
+ // orders of magnitude under the real figure while reporting warnings: [].
130
+ const hidden = perRoot.reduce((n, r) => n + r.hiddenSkipped, 0)
131
+ if (hidden) {
132
+ warnings.push(
133
+ `${hidden} hidden item(s) excluded from these totals. Pass include_hidden: true ` +
134
+ 'to count them.',
135
+ )
136
+ }
137
+ const skippedDirs = perRoot.flatMap((r) => r.skippedDirectories ?? [])
138
+ if (skippedDirs.length) {
139
+ warnings.push(
140
+ `${skippedDirs.length} build/cache director(ies) excluded and NOT counted in ` +
141
+ `these totals (${skippedDirs.slice(0, 3).map((d) => d.split('/').pop()).join(', ')}` +
142
+ `${skippedDirs.length > 3 ? ', …' : ''}). They are often the largest thing on disk.`,
143
+ )
144
+ }
145
+ const hardlinked = perRoot.reduce((n, r) => n + (r.hardlinked?.length ?? 0), 0)
146
+ if (hardlinked) {
147
+ warnings.push(
148
+ `${hardlinked} file(s) are hardlinked, so the same bytes may be counted here and ` +
149
+ 'also reachable under another name outside these roots.',
150
+ )
151
+ }
152
+ const depth = perRoot.reduce((n, r) => n + r.depthLimited.length, 0)
153
+ if (depth) {
154
+ warnings.push(`${depth} directory branch(es) exceeded the depth limit and were not walked.`)
155
+ }
156
+ return warnings
157
+ }
package/src/fsops.mjs ADDED
@@ -0,0 +1,361 @@
1
+ // Filesystem moves that never replace what is already there.
2
+ //
3
+ // rename(2) silently replaces a file, a symlink or an empty directory at its
4
+ // destination. Node exposes neither renameat2(RENAME_NOREPLACE) nor
5
+ // renamex_np(RENAME_EXCL), so a "never replaces" promise built on a check
6
+ // followed by rename() holds only until something appears in between. Every
7
+ // move in this server goes through renameNoReplace() instead, which uses an
8
+ // operation that fails when the destination exists wherever the platform has
9
+ // one, and says plainly where it does not.
10
+
11
+ import { constants } from 'node:fs'
12
+ import {
13
+ copyFile,
14
+ link,
15
+ lstat,
16
+ mkdir,
17
+ open,
18
+ readdir,
19
+ readlink,
20
+ rename,
21
+ rmdir,
22
+ symlink,
23
+ unlink,
24
+ } from 'node:fs/promises'
25
+ import { dirname, join } from 'node:path'
26
+
27
+ import { quote } from './format.mjs'
28
+ import { isInside, ToolError } from './roots.mjs'
29
+ import { hashFile } from './scan.mjs'
30
+
31
+ /** Errors meaning "this filesystem cannot make that kind of name", rather than a real failure. */
32
+ const NAME_UNSUPPORTED = new Set(['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EMLINK', 'EINVAL'])
33
+
34
+ export function destinationExists(path) {
35
+ return new ToolError(
36
+ 'destination_exists',
37
+ `Refused: ${quote(path)} already exists, so nothing was moved and nothing was replaced.`,
38
+ )
39
+ }
40
+
41
+ /**
42
+ * Move `from` to `to`, refusing if anything at all is at `to`.
43
+ *
44
+ * - A regular file is hard-linked to the new name, which fails if anything --
45
+ * even a dangling symlink -- is already there, and only then unlinked from
46
+ * the old one.
47
+ * - A symlink is recreated at the new name with the same target, and then the
48
+ * old one is removed. link(2) cannot be used for it: on macOS it follows the
49
+ * link and hard-links the target instead.
50
+ * - A directory gets an empty placeholder made at the new name, and rename(2)
51
+ * replaces only that. Anything written into the placeholder in the gap makes
52
+ * the rename fail with ENOTEMPTY rather than be replaced. What remains is that
53
+ * an EMPTY directory created in place of the placeholder in that gap would be
54
+ * replaced, which loses nothing.
55
+ * - FIFOs, sockets and device files, filesystems without hard links (FAT, exFAT
56
+ * and some network shares), and directories on Windows fall back to checking
57
+ * and then renaming, which leaves a window in which a file created at `to` by
58
+ * another process would be replaced. SECURITY.md records it.
59
+ *
60
+ * EXDEV propagates untouched, so the caller decides what a move across devices
61
+ * means. `fromStats` is the lstat of `from`, when the caller already has it.
62
+ */
63
+ export async function renameNoReplace(from, to, fromStats) {
64
+ const stats = fromStats ?? (await lstat(from))
65
+
66
+ if (stats.isFile()) {
67
+ return swapName(from, to, () => link(from, to), async () => {
68
+ const now = await lstat(to)
69
+ return now.ino === stats.ino && now.dev === stats.dev
70
+ })
71
+ }
72
+ if (stats.isSymbolicLink()) {
73
+ const target = await readlink(from)
74
+ return swapName(from, to, () => symlink(target, to), async () => {
75
+ return (await lstat(to)).isSymbolicLink() && (await readlink(to)) === target
76
+ })
77
+ }
78
+ if (stats.isDirectory() && process.platform !== 'win32') {
79
+ return renameOverPlaceholder(from, to)
80
+ }
81
+ return renameChecked(from, to)
82
+ }
83
+
84
+ async function swapName(from, to, makeName, isOurs) {
85
+ try {
86
+ await makeName()
87
+ } catch (err) {
88
+ if (err.code === 'EEXIST') throw destinationExists(to)
89
+ if (NAME_UNSUPPORTED.has(err.code)) return renameChecked(from, to)
90
+ throw err
91
+ }
92
+ try {
93
+ await unlink(from)
94
+ } catch (err) {
95
+ // The new name exists and the old one could not be removed. Take back the
96
+ // name this call made -- only if it is still that name -- so a failure
97
+ // leaves the item under exactly one name, the one it had.
98
+ if (await isOurs().catch(() => false)) await unlink(to).catch(() => {})
99
+ throw err
100
+ }
101
+ }
102
+
103
+ async function renameOverPlaceholder(from, to) {
104
+ try {
105
+ await mkdir(to)
106
+ } catch (err) {
107
+ if (err.code === 'EEXIST') throw destinationExists(to)
108
+ throw err
109
+ }
110
+ try {
111
+ await rename(from, to)
112
+ } catch (err) {
113
+ // rmdir removes only an empty directory: the placeholder, and never
114
+ // anything that landed inside it.
115
+ await rmdir(to).catch(() => {})
116
+ if (err.code === 'ENOTEMPTY' || err.code === 'EEXIST') throw destinationExists(to)
117
+ throw err
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Flush a directory, so a rename just made in it survives a power cut.
123
+ *
124
+ * Best effort. Not every platform lets a directory be opened and flushed, and a
125
+ * failure here undoes nothing that has already happened.
126
+ */
127
+ export async function syncDirectory(path) {
128
+ let handle
129
+ try {
130
+ handle = await open(path, 'r')
131
+ await handle.sync()
132
+ } catch {
133
+ // See above: nothing to report and nothing to undo.
134
+ } finally {
135
+ await handle?.close().catch(() => {})
136
+ }
137
+ }
138
+
139
+ async function renameChecked(from, to) {
140
+ let present = true
141
+ try {
142
+ await lstat(to)
143
+ } catch (err) {
144
+ if (err.code !== 'ENOENT') throw err
145
+ present = false
146
+ }
147
+ if (present) throw destinationExists(to)
148
+ await rename(from, to)
149
+ }
150
+
151
+ // Moves that cross a device.
152
+ //
153
+ // rename(2) cannot cross a filesystem boundary, so such a move is a copy
154
+ // followed by removing the source: the one place this server removes anything
155
+ // a user made. The old fallback began by rm -rf'ing whatever sat at its staging
156
+ // name, copied files and directories only -- so a FIFO, socket or device file
157
+ // in the tree was dropped -- and removed a file's source without flushing or
158
+ // checking the copy. The rules now:
159
+ //
160
+ // - Nothing that already exists is removed or replaced. A copy is made under a
161
+ // fresh name, created exclusively, and put in place with renameNoReplace().
162
+ // - Only what a copy can carry is copied. A symlink or a special file in a tree
163
+ // is refused before anything of the source is removed.
164
+ // - Every file is flushed to disk and verified -- same size and SHA-256, and a
165
+ // source that has not changed since it was copied -- before anything goes.
166
+ // - The source is removed entry by entry: a file only if it is still the file
167
+ // that was copied, a directory only once it is empty. Whatever appeared or
168
+ // changed during the move stays where it is, and the caller is told.
169
+
170
+ function refuseIfCancelled(signal) {
171
+ if (signal?.aborted) {
172
+ throw new ToolError(
173
+ 'cancelled',
174
+ 'Cancelled before the copy was put in place. Nothing was moved, and the partial copy was removed.',
175
+ )
176
+ }
177
+ }
178
+
179
+ let incomingCounter = 0
180
+
181
+ /** A name beside `to` that nothing else uses, for a copy on its way in. */
182
+ function incomingName(to) {
183
+ incomingCounter += 1
184
+ return join(dirname(to), `.shieldfive-mcp-incoming-${process.pid}-${incomingCounter}`)
185
+ }
186
+
187
+ /** Flush a file's bytes to disk. A read-only descriptor is enough, so a read-only copy can be flushed too. */
188
+ export async function syncFile(path) {
189
+ const handle = await open(path, 'r')
190
+ try {
191
+ await handle.sync()
192
+ } finally {
193
+ await handle.close()
194
+ }
195
+ }
196
+
197
+ function unchanged(before, now) {
198
+ return (
199
+ now.ino === before.ino &&
200
+ now.dev === before.dev &&
201
+ now.size === before.size &&
202
+ now.mtimeMs === before.mtimeMs
203
+ )
204
+ }
205
+
206
+ function describeSpecial(stats) {
207
+ if (stats.isFIFO()) return 'named pipe (FIFO)'
208
+ if (stats.isSocket()) return 'socket'
209
+ if (stats.isBlockDevice() || stats.isCharacterDevice()) return 'device file'
210
+ return 'special file'
211
+ }
212
+
213
+ async function verifyCopy(source, before, copy) {
214
+ const changed = () =>
215
+ new ToolError(
216
+ 'source_changed',
217
+ `Refused: ${quote(source)} changed while it was being copied, so the copy cannot be ` +
218
+ 'trusted. The copy was discarded and the source was not removed; try again once ' +
219
+ 'nothing is writing to it.',
220
+ )
221
+ if (!unchanged(before, await lstat(source))) throw changed()
222
+
223
+ const copied = await lstat(copy)
224
+ const sourceDigest = await hashFile(source)
225
+ const copyDigest = await hashFile(copy)
226
+ if (!unchanged(before, await lstat(source))) throw changed()
227
+ if (copied.size !== before.size || sourceDigest !== copyDigest) {
228
+ throw new ToolError(
229
+ 'copy_verification_failed',
230
+ `Refused: the copy of ${quote(source)} does not match it (` +
231
+ (copied.size !== before.size
232
+ ? `${copied.size} bytes instead of ${before.size}`
233
+ : 'the same size, but a different SHA-256') +
234
+ '). The copy was discarded and the source was not removed.',
235
+ )
236
+ }
237
+ }
238
+
239
+ /** Remove sources that have a verified copy, each only if it is still what was copied. */
240
+ async function removeCopied(copied) {
241
+ const left = []
242
+ for (const { source, stats } of copied) {
243
+ let now
244
+ try {
245
+ now = await lstat(source)
246
+ } catch {
247
+ continue
248
+ }
249
+ if (!unchanged(stats, now)) {
250
+ left.push(source)
251
+ continue
252
+ }
253
+ try {
254
+ await unlink(source)
255
+ } catch {
256
+ left.push(source)
257
+ }
258
+ }
259
+ return left
260
+ }
261
+
262
+ /**
263
+ * Move one regular file to another device.
264
+ *
265
+ * Returns the source paths left in place: empty, unless the source changed
266
+ * after its copy was verified.
267
+ */
268
+ export async function moveFileAcrossDevices(from, to, before, signal) {
269
+ const incoming = incomingName(to)
270
+ try {
271
+ await copyFile(from, incoming, constants.COPYFILE_EXCL)
272
+ await syncFile(incoming)
273
+ await verifyCopy(from, before, incoming)
274
+ refuseIfCancelled(signal)
275
+ await renameNoReplace(incoming, to)
276
+ } catch (err) {
277
+ // The incoming name was created exclusively, so whatever is there is this
278
+ // call's own partial or unverified copy -- unless the name was taken, and
279
+ // then nothing is removed.
280
+ if (err.code !== 'EEXIST') await unlink(incoming).catch(() => {})
281
+ throw err
282
+ }
283
+ await syncDirectory(dirname(to))
284
+ return removeCopied([{ source: from, stats: before }])
285
+ }
286
+
287
+ /**
288
+ * Move a directory tree to another device.
289
+ *
290
+ * The tree is copied into a fresh staging directory beside the destination,
291
+ * each file verified, and the staging directory renamed into place only once
292
+ * all of it has landed. Returns the source paths left in place.
293
+ */
294
+ export async function moveTreeAcrossDevices(from, to, signal) {
295
+ const staging = incomingName(to)
296
+ await mkdir(staging)
297
+ const made = { files: [], dirs: [staging] }
298
+ const copied = []
299
+ const sourceDirs = [from]
300
+ try {
301
+ await copyTreeVerified(from, staging, made, copied, sourceDirs, signal)
302
+ refuseIfCancelled(signal)
303
+ await renameNoReplace(staging, to)
304
+ } catch (err) {
305
+ for (const file of made.files) await unlink(file).catch(() => {})
306
+ for (const dir of [...made.dirs].reverse()) await rmdir(dir).catch(() => {})
307
+ throw err
308
+ }
309
+ await syncDirectory(dirname(to))
310
+
311
+ const left = await removeCopied(copied)
312
+ for (const dir of [...sourceDirs].reverse()) {
313
+ try {
314
+ await rmdir(dir)
315
+ } catch {
316
+ if (!left.some((p) => isInside(p, dir))) left.push(dir)
317
+ }
318
+ }
319
+ return left
320
+ }
321
+
322
+ async function copyTreeVerified(fromDir, toDir, made, copied, sourceDirs, signal) {
323
+ for (const name of await readdir(fromDir)) {
324
+ refuseIfCancelled(signal)
325
+ const source = join(fromDir, name)
326
+ const target = join(toDir, name)
327
+ const stats = await lstat(source)
328
+
329
+ if (stats.isSymbolicLink()) {
330
+ throw new ToolError(
331
+ 'symlink_in_tree',
332
+ `Refused: ${quote(source)} is a symlink, and this move crosses a filesystem boundary, ` +
333
+ 'so it would have to be copied, and a copy cannot keep it a link. Nothing was ' +
334
+ 'removed and nothing is left at the destination. Move the link yourself or remove it first.',
335
+ )
336
+ }
337
+ if (stats.isDirectory()) {
338
+ await mkdir(target)
339
+ made.dirs.push(target)
340
+ sourceDirs.push(source)
341
+ await copyTreeVerified(source, target, made, copied, sourceDirs, signal)
342
+ continue
343
+ }
344
+ if (!stats.isFile()) {
345
+ throw new ToolError(
346
+ 'special_file_in_tree',
347
+ `Refused: ${quote(source)} is a ${describeSpecial(stats)}, which cannot be copied to ` +
348
+ 'another filesystem. Nothing was removed and nothing is left at the destination. ' +
349
+ 'Move it yourself, or move the rest without it.',
350
+ )
351
+ }
352
+
353
+ made.files.push(target)
354
+ await copyFile(source, target, constants.COPYFILE_EXCL)
355
+ await syncFile(target)
356
+ await verifyCopy(source, stats, target)
357
+ copied.push({ source, stats })
358
+ }
359
+ }
360
+
361
+ export { describeSpecial }
package/src/limits.mjs ADDED
@@ -0,0 +1,55 @@
1
+ // Upper bounds on what a caller may ask for.
2
+ //
3
+ // The MCP schema in server.mjs applies these before a handler runs, and the
4
+ // handlers apply them again, so a handler reached any other way enforces the
5
+ // same bounds. Before they existed, limit, max_files, max_files_hashed, paths
6
+ // and new_name had no upper bound: a caller could ask for a ten-million-row
7
+ // payload, a scan with no effective cap, or a name longer than any filesystem
8
+ // accepts, which then came back whole in the refusal.
9
+
10
+ import { quote } from './format.mjs'
11
+ import { MAX_PATH_CHARS, ToolError } from './roots.mjs'
12
+
13
+ export const LIMITS = Object.freeze({
14
+ /** Rows a listing returns. */
15
+ limit: 10_000,
16
+ /** Files one scan may walk, across every root. The default is 200,000. */
17
+ maxFiles: 1_000_000,
18
+ /** Hash reads find_duplicates may spend. The default is 20,000. */
19
+ maxFilesHashed: 1_000_000,
20
+ /** Paths one trash_local call may take. */
21
+ paths: 1_000,
22
+ /** Characters in a path argument. */
23
+ pathChars: MAX_PATH_CHARS,
24
+ /** UTF-8 bytes in a file name: NAME_MAX on APFS, ext4 and most others. */
25
+ nameBytes: 255,
26
+ })
27
+
28
+ /** An optional positive whole-number argument, defaulted and bounded. */
29
+ export function boundedInt(value, { name, max, fallback }) {
30
+ if (value === undefined || value === null) return fallback
31
+ if (!Number.isSafeInteger(value) || value < 1 || value > max) {
32
+ throw new ToolError(
33
+ 'invalid_argument',
34
+ `${name} must be a whole number from 1 to ${max.toLocaleString('en-US')}; ` +
35
+ `got ${quote(value, 40)}.`,
36
+ )
37
+ }
38
+ return value
39
+ }
40
+
41
+ /** A list argument with at least one and at most `max` entries. */
42
+ export function boundedList(value, { name, max }) {
43
+ const list = Array.isArray(value) ? value : value === undefined ? [] : [value]
44
+ if (list.length === 0) {
45
+ throw new ToolError('invalid_path', `At least one entry in ${name} is required.`)
46
+ }
47
+ if (list.length > max) {
48
+ throw new ToolError(
49
+ 'invalid_argument',
50
+ `${name} may hold at most ${max.toLocaleString('en-US')} entries; got ` +
51
+ `${list.length.toLocaleString('en-US')}. Split the call.`,
52
+ )
53
+ }
54
+ return list
55
+ }