@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/scan.mjs ADDED
@@ -0,0 +1,269 @@
1
+ // Directory walking and content hashing.
2
+ //
3
+ // Symlinks are never followed. Every entry is inspected with lstat, and a link
4
+ // is counted and skipped rather than traversed. That is a containment decision
5
+ // before it is a loop-avoidance one: following a link is exactly how a walk
6
+ // leaves the configured root, and roots.mjs cannot re-check a path the walk
7
+ // never surfaced.
8
+ //
9
+ // Nothing here is silently truncated. When a cap is hit the result says so and
10
+ // names the number, because a listing that quietly stops is read as a complete
11
+ // listing and the conclusions drawn from it are wrong in a way nobody notices.
12
+
13
+ import { createHash } from 'node:crypto'
14
+ import { createReadStream } from 'node:fs'
15
+ import { opendir, lstat } from 'node:fs/promises'
16
+ import { extname, join, relative } from 'node:path'
17
+
18
+ /** Where trash_local moves things. Never walked; see DEFAULT_SKIP_DIRS. */
19
+ export const TRASH_DIR_NAME = '.shieldfive-mcp-trash'
20
+
21
+ /** Entries whose names are noise in every report. */
22
+ const ALWAYS_SKIP = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini'])
23
+
24
+ /**
25
+ * Directories excluded from every walk: build output, package caches and VCS
26
+ * metadata.
27
+ *
28
+ * `build`, `dist` and `target` are ordinary folder names outside a code tree,
29
+ * so this list can hide real user data. Every exclusion is counted and reported
30
+ * in the scan warnings, and there is no way to override the list yet.
31
+ */
32
+ const DEFAULT_SKIP_DIRS = new Set([
33
+ 'node_modules',
34
+ '.git',
35
+ '.svn',
36
+ '.hg',
37
+ '.cache',
38
+ '.venv',
39
+ 'venv',
40
+ '__pycache__',
41
+ '.next',
42
+ '.turbo',
43
+ 'dist',
44
+ 'build',
45
+ 'target',
46
+ 'Pods',
47
+ '.gradle',
48
+ '.tox',
49
+ '.mypy_cache',
50
+ '.pytest_cache',
51
+ // This server's own trash. Walking it would offer already-trashed files back
52
+ // as fresh candidates on the next scan.
53
+ TRASH_DIR_NAME,
54
+ ])
55
+
56
+ /**
57
+ * Walk one directory tree, breadth-first.
58
+ *
59
+ * `maxDepth` counts levels BELOW the start: the start directory is depth 0, so
60
+ * the default of 64 walks the start and 64 levels of subdirectories beneath it.
61
+ *
62
+ * Every field of `stats` is read by name elsewhere: scanWarnings() in format.mjs
63
+ * turns the counters into the sentences a user sees, and read.mjs copies a few
64
+ * into its payloads. Adding a field is safe; renaming one silently drops a
65
+ * warning.
66
+ *
67
+ * @returns {Promise<{
68
+ * files: Array<{path, relativePath, size, mtimeMs, extension, hardlinked, dev, ino, nlink}>,
69
+ * stats: {
70
+ * directories: number, symlinksSkipped: number, hiddenSkipped: number,
71
+ * skippedDirectories: string[], hardlinked: string[],
72
+ * unreadable: Array<{path, code}>, depthLimited: string[],
73
+ * truncated: boolean, maxFiles: number,
74
+ * },
75
+ * }>}
76
+ */
77
+ export async function walk(
78
+ startRealPath,
79
+ {
80
+ maxFiles = 200_000,
81
+ maxDepth = 64,
82
+ includeHidden = false,
83
+ skipDirs = DEFAULT_SKIP_DIRS,
84
+ signal,
85
+ } = {},
86
+ ) {
87
+ const files = []
88
+ const stats = {
89
+ directories: 0,
90
+ symlinksSkipped: 0,
91
+ hiddenSkipped: 0,
92
+ skippedDirectories: [],
93
+ hardlinked: [],
94
+ unreadable: [],
95
+ depthLimited: [],
96
+ truncated: false,
97
+ maxFiles,
98
+ }
99
+
100
+ const queue = [{ dir: startRealPath, depth: 0 }]
101
+
102
+ while (queue.length) {
103
+ signal?.throwIfAborted()
104
+ const { dir, depth } = queue.shift()
105
+
106
+ if (depth > maxDepth) {
107
+ stats.depthLimited.push(dir)
108
+ continue
109
+ }
110
+
111
+ let handle
112
+ try {
113
+ handle = await opendir(dir)
114
+ } catch (err) {
115
+ stats.unreadable.push({ path: dir, code: err.code })
116
+ continue
117
+ }
118
+ stats.directories++
119
+
120
+ try {
121
+ for await (const entry of handle) {
122
+ if (files.length >= maxFiles) {
123
+ stats.truncated = true
124
+ break
125
+ }
126
+ if (ALWAYS_SKIP.has(entry.name)) continue
127
+
128
+ const full = join(dir, entry.name)
129
+
130
+ // lstat before any name-based branch. The hidden check used to
131
+ // run on the dirent, so a dot-named symlink was consumed as "hidden"
132
+ // and never reached the symlink branch -- two links on disk, one
133
+ // reported. Trust lstat, not the dirent flags: a dirent can report
134
+ // DT_UNKNOWN on some filesystems, and isSymbolicLink() has to be
135
+ // authoritative here.
136
+ let st
137
+ try {
138
+ st = await lstat(full)
139
+ } catch (err) {
140
+ stats.unreadable.push({ path: full, code: err.code })
141
+ continue
142
+ }
143
+
144
+ if (st.isSymbolicLink()) {
145
+ stats.symlinksSkipped++
146
+ continue
147
+ }
148
+
149
+ const hidden = entry.name.startsWith('.')
150
+
151
+ if (st.isDirectory()) {
152
+ if (skipDirs.has(entry.name)) {
153
+ stats.skippedDirectories.push(full)
154
+ continue
155
+ }
156
+ if (!includeHidden && hidden) {
157
+ stats.hiddenSkipped++
158
+ continue
159
+ }
160
+ queue.push({ dir: full, depth: depth + 1 })
161
+ continue
162
+ }
163
+
164
+ if (!includeHidden && hidden) {
165
+ stats.hiddenSkipped++
166
+ continue
167
+ }
168
+
169
+ if (!st.isFile()) continue
170
+
171
+ // nlink > 1 means this inode is reachable by another name, possibly
172
+ // one outside every root. realpath resolves symlinks but not hardlinks,
173
+ // so containment cannot see that second name. Counting them is the
174
+ // honest response: it is reported, not silently trusted.
175
+ if (st.nlink > 1) stats.hardlinked.push(full)
176
+
177
+ files.push({
178
+ path: full,
179
+ relativePath: relative(startRealPath, full),
180
+ size: st.size,
181
+ mtimeMs: st.mtimeMs,
182
+ extension: extname(entry.name).toLowerCase(),
183
+ hardlinked: st.nlink > 1,
184
+ // Recorded so find_duplicates can tell two names of one file from two
185
+ // copies. Without them a hardlinked pair was reported as a duplicate
186
+ // whose removal would free space, and it frees none.
187
+ dev: st.dev,
188
+ ino: st.ino,
189
+ nlink: st.nlink,
190
+ })
191
+ }
192
+ } finally {
193
+ // `for await` closes the handle on normal completion; an early `break`
194
+ // leaves it open, and an unclosed dir handle is a real leak on a long-
195
+ // lived stdio server.
196
+ await handle.close().catch(() => {})
197
+ }
198
+
199
+ if (stats.truncated) break
200
+ }
201
+
202
+ return { files, stats }
203
+ }
204
+
205
+ /**
206
+ * Walk every root, tagging each file with the root it came from.
207
+ *
208
+ * `notScanned` lists the roots that were never walked because the file budget
209
+ * ran out first. They used to be walked with a cap of zero and then dropped
210
+ * from the per-root results, while every tool still listed them as scanned.
211
+ */
212
+ export async function walkRoots(rootSet, options = {}) {
213
+ const files = []
214
+ const perRoot = []
215
+ const notScanned = []
216
+ const budget = options.maxFiles ?? 200_000
217
+
218
+ for (const root of rootSet) {
219
+ // A shared budget, decremented per root. Passing the same maxFiles to each
220
+ // walk made the documented cap a PER-ROOT cap, so N roots returned up to N
221
+ // times the number the caller asked for.
222
+ const remaining = budget - files.length
223
+ if (remaining <= 0 || perRoot.some((r) => r.truncated)) {
224
+ notScanned.push(root.realPath)
225
+ continue
226
+ }
227
+
228
+ const result = await walk(root.realPath, { ...options, maxFiles: remaining })
229
+
230
+ for (const f of result.files) {
231
+ f.root = root.realPath
232
+ // NOT files.push(...result.files): spreading an array as call arguments
233
+ // exceeds V8's argument limit at roughly 125,000 elements and throws
234
+ // RangeError, which made every scanning tool crash on a large root well
235
+ // below the 200,000-file cap the schema advertises.
236
+ files.push(f)
237
+ }
238
+
239
+ perRoot.push({
240
+ root: root.realPath,
241
+ ...result.stats,
242
+ maxFiles: budget,
243
+ files: result.files.length,
244
+ })
245
+ }
246
+
247
+ return { files, perRoot, notScanned }
248
+ }
249
+
250
+ /**
251
+ * SHA-256 of a file's bytes, optionally only the first `limit` bytes.
252
+ *
253
+ * Content identity is established by hashing content. Nothing in this server
254
+ * treats matching name and size as evidence that two files are the same — that
255
+ * inference is wrong often enough to delete the wrong copy, which is the one
256
+ * mistake this tool must never make.
257
+ */
258
+ export function hashFile(path, { limit = Infinity, signal } = {}) {
259
+ return new Promise((resolvePromise, reject) => {
260
+ const hash = createHash('sha256')
261
+ const stream = createReadStream(path, {
262
+ end: Number.isFinite(limit) ? limit - 1 : undefined,
263
+ signal,
264
+ })
265
+ stream.on('error', reject)
266
+ stream.on('data', (chunk) => hash.update(chunk))
267
+ stream.on('end', () => resolvePromise(hash.digest('hex')))
268
+ })
269
+ }
package/src/server.mjs ADDED
@@ -0,0 +1,363 @@
1
+ #!/usr/bin/env node
2
+ // @shieldfive/mcp — a Model Context Protocol server for local file management.
3
+ //
4
+ // WHAT THIS SERVER DOES NOT DO, AND WHY THAT IS THE DESIGN
5
+ //
6
+ // It holds no ShieldFive credential, makes no network request, and does not
7
+ // import @shieldfive/crypto. That is not a gap to be filled later; it is the
8
+ // security boundary, expressed as an absence.
9
+ //
10
+ // The alternative was to authenticate with a full ShieldFive account JWT. That
11
+ // token also opens /api/vault-key — the wrapped root key and an ML-KEM public
12
+ // key — and every content-download route, and none of it can be scoped away,
13
+ // because no scoped vault credential exists. A server holding that token would
14
+ // be DECLINING to read your files rather than being UNABLE to, with the
15
+ // difference resting on a client-side denylist and on the token file not being
16
+ // read by anything else on the machine. A server holding no token cannot read
17
+ // them at all. See docs/mcp-v1-step0-discovery.md in shieldfive/web for the
18
+ // full argument, and README.md § "What this cannot do" for the consequences.
19
+ //
20
+ // The cost: v1 cannot tell you whether a local file is already backed up. It
21
+ // will not guess either, because matching a filename and a size against a vault
22
+ // listing is how a tool deletes the only copy of something.
23
+
24
+ import { readFileSync, realpathSync } from 'node:fs'
25
+ import { fileURLToPath } from 'node:url'
26
+
27
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
28
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
29
+ import { z } from 'zod'
30
+
31
+ import { toolFailure } from './format.mjs'
32
+ import { LIMITS } from './limits.mjs'
33
+ import { createPlanStore } from './plans.mjs'
34
+ import { NO_ROOTS_MESSAGE, resolveRoots, rootCandidatesFrom, ToolError } from './roots.mjs'
35
+ import {
36
+ findDuplicates,
37
+ findLargeFiles,
38
+ findOldFiles,
39
+ listLocal,
40
+ storageSummary,
41
+ } from './tools/read.mjs'
42
+ import { createLocalFolder, moveLocal, renameLocal, trashLocal } from './tools/mutate.mjs'
43
+
44
+ // Read from package.json rather than repeated here. A second copy had no test,
45
+ // and the first release that forgot to bump it would have reported the old one.
46
+ export const VERSION = JSON.parse(
47
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
48
+ ).version
49
+
50
+ /** stdout is the protocol channel. Everything human goes to stderr. */
51
+ const log = (...parts) => process.stderr.write(`[shieldfive-mcp] ${parts.join(' ')}\n`)
52
+
53
+ // Every cap here is also applied by the handler; see limits.mjs. The path cap
54
+ // is not about the filesystem: without it a 5 MB path argument was reflected
55
+ // verbatim into the error message and landed 1:1 in the model's context.
56
+ const pathArg = z
57
+ .string()
58
+ .max(LIMITS.pathChars, 'path is longer than any filesystem accepts')
59
+ .describe('Absolute path. Must resolve inside a configured root; relative paths are refused.')
60
+
61
+ // A confirmed call carries the token its own preview returned; see plans.mjs.
62
+ const planTokenArg = z
63
+ .string()
64
+ .optional()
65
+ .describe(
66
+ 'The plan_token this tool returned when called without confirm. Required with ' +
67
+ 'confirm: true, single use, and only valid while the plan still matches the tree.',
68
+ )
69
+
70
+ const scanArgs = {
71
+ path: pathArg.optional().describe('Directory to scan. Omit to scan every configured root.'),
72
+ include_hidden: z.boolean().optional().describe('Include dotfiles and dot-directories.'),
73
+ max_files: z
74
+ .number()
75
+ .int()
76
+ .positive()
77
+ .max(LIMITS.maxFiles)
78
+ .optional()
79
+ .describe('Stop after this many files, at most 1,000,000. The result says so when the cap is hit.'),
80
+ limit: z
81
+ .number()
82
+ .int()
83
+ .positive()
84
+ .max(LIMITS.limit)
85
+ .optional()
86
+ .describe('Maximum rows to return, at most 10,000.'),
87
+ }
88
+
89
+ const TOOLS = [
90
+ {
91
+ name: 'list_local',
92
+ title: 'List local files',
93
+ description:
94
+ 'List files in an allowed local directory, with sizes and modification dates. ' +
95
+ 'Never follows symlinks and never leaves the configured roots.',
96
+ inputSchema: { ...scanArgs, sort_by: z.enum(['path', 'size', 'modified']).optional() },
97
+ annotations: { readOnlyHint: true, openWorldHint: false },
98
+ handler: listLocal,
99
+ },
100
+ {
101
+ name: 'find_duplicates',
102
+ title: 'Find duplicate files',
103
+ description:
104
+ 'Find files with byte-identical contents. Identity is decided by a full SHA-256 ' +
105
+ 'of each file, never by matching names or sizes. Reports how much space keeping ' +
106
+ 'one copy of each would reclaim.',
107
+ inputSchema: {
108
+ ...scanArgs,
109
+ min_bytes: z
110
+ .number()
111
+ .int()
112
+ .nonnegative()
113
+ .optional()
114
+ .describe('Ignore files smaller than this. Default 1.'),
115
+ max_files_hashed: z
116
+ .number()
117
+ .int()
118
+ .positive()
119
+ .max(LIMITS.maxFilesHashed)
120
+ .optional()
121
+ .describe('Hashing budget in reads, at most 1,000,000. When reached, the result says it is a lower bound.'),
122
+ },
123
+ annotations: { readOnlyHint: true, openWorldHint: false },
124
+ handler: findDuplicates,
125
+ },
126
+ {
127
+ name: 'find_large_files',
128
+ title: 'Find large files',
129
+ description: 'List files at or above a size threshold, largest first.',
130
+ inputSchema: {
131
+ ...scanArgs,
132
+ min_bytes: z.number().int().positive().optional().describe('Default 100 MB.'),
133
+ },
134
+ annotations: { readOnlyHint: true, openWorldHint: false },
135
+ handler: findLargeFiles,
136
+ },
137
+ {
138
+ name: 'find_old_files',
139
+ title: 'Find stale files',
140
+ description:
141
+ 'List files not modified for a given number of days. Modification time is a weak ' +
142
+ 'signal and the result says so; treat the output as a shortlist to review.',
143
+ inputSchema: {
144
+ ...scanArgs,
145
+ older_than_days: z.number().int().positive().optional().describe('Default 365.'),
146
+ },
147
+ annotations: { readOnlyHint: true, openWorldHint: false },
148
+ handler: findOldFiles,
149
+ },
150
+ {
151
+ name: 'storage_summary',
152
+ title: 'Summarise local storage',
153
+ description:
154
+ 'Total file count and bytes for the allowed roots, broken down by file extension ' +
155
+ 'and by directory. Reports file-content sizes, which will not match a disk ' +
156
+ 'utility exactly.',
157
+ inputSchema: scanArgs,
158
+ annotations: { readOnlyHint: true, openWorldHint: false },
159
+ handler: storageSummary,
160
+ },
161
+ {
162
+ name: 'move_local',
163
+ title: 'Move a file or folder',
164
+ description:
165
+ 'Move a file, directory or symlink to another location inside the allowed roots. ' +
166
+ 'A symlink is moved itself, never what it points to. Without confirm: true this ' +
167
+ 'only reports what it would do. Refuses to overwrite unless overwrite: true is also ' +
168
+ 'passed, and then moves what was there to the trash. Between volumes the move is a ' +
169
+ 'copy, verified before the source is removed.',
170
+ inputSchema: {
171
+ source: pathArg,
172
+ destination: pathArg.describe(
173
+ 'Absolute destination. If it is an existing directory, the source is moved into it.',
174
+ ),
175
+ overwrite: z.boolean().optional().describe('Replace the destination if it exists.'),
176
+ confirm: z.boolean().optional().describe('Required to actually move anything.'),
177
+ plan_token: planTokenArg
178
+ },
179
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
180
+ handler: moveLocal,
181
+ },
182
+ {
183
+ name: 'rename_local',
184
+ title: 'Rename a file or folder',
185
+ description:
186
+ 'Rename an item in place; a symlink is renamed itself. new_name must be a bare ' +
187
+ 'filename, not a path, and is used exactly as given. Never replaces an existing ' +
188
+ 'file. Without confirm: true this only reports the plan.',
189
+ inputSchema: {
190
+ path: pathArg,
191
+ new_name: z
192
+ .string()
193
+ .max(LIMITS.nameBytes)
194
+ .describe('The new filename, used exactly as given: no directory separators, at most 255 bytes.'),
195
+ confirm: z.boolean().optional().describe('Required to actually rename.'),
196
+ plan_token: planTokenArg
197
+ },
198
+ annotations: {
199
+ readOnlyHint: false,
200
+ destructiveHint: false,
201
+ idempotentHint: false,
202
+ openWorldHint: false,
203
+ },
204
+ handler: renameLocal,
205
+ },
206
+ {
207
+ name: 'create_local_folder',
208
+ title: 'Create a folder',
209
+ description:
210
+ 'Create a directory (and any missing parents) inside the allowed roots. ' +
211
+ 'Without confirm: true this only reports the plan.',
212
+ inputSchema: {
213
+ path: pathArg,
214
+ confirm: z.boolean().optional().describe('Required to actually create it.'),
215
+ plan_token: planTokenArg
216
+ },
217
+ annotations: {
218
+ readOnlyHint: false,
219
+ destructiveHint: false,
220
+ idempotentHint: true,
221
+ openWorldHint: false,
222
+ },
223
+ handler: createLocalFolder,
224
+ },
225
+ {
226
+ name: 'trash_local',
227
+ title: 'Move files to this server’s trash',
228
+ description:
229
+ 'Move files or folders into a .shieldfive-mcp-trash directory inside their own ' +
230
+ 'root and on their own volume, with a manifest recording where each came from. A ' +
231
+ 'symlink is trashed itself, never what it points to. NOTHING IS DELETED and no ' +
232
+ 'disk space is freed — the bytes stay on the same volume until you empty that ' +
233
+ 'directory yourself. Without confirm: true this only reports the plan.',
234
+ inputSchema: {
235
+ paths: z
236
+ .array(pathArg)
237
+ .min(1)
238
+ .max(LIMITS.paths)
239
+ .describe('Absolute paths to move into the trash, at most 1,000.'),
240
+ confirm: z.boolean().optional().describe('Required to actually move anything.'),
241
+ plan_token: planTokenArg
242
+ },
243
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
244
+ handler: trashLocal,
245
+ },
246
+ ]
247
+
248
+ /**
249
+ * Run one tool call and render its result or its refusal.
250
+ *
251
+ * extra.signal is aborted when the client cancels the request, and the SDK then
252
+ * sends no response at all. For a tool that changes files the outcome is
253
+ * therefore written to the log, the only record left of what a cancelled call
254
+ * did. Every error raised after a cancellation used to be replaced with
255
+ * "Cancelled.", including a partial trash carrying its account of what had
256
+ * already moved; a mutation's own error now passes through intact, detail
257
+ * included, and only a read-only tool's abort becomes a plain cancellation.
258
+ */
259
+ export async function runTool(tool, ctx, args, extra, write = log) {
260
+ const signal = extra?.signal
261
+ const mutates = tool.annotations?.readOnlyHint === false
262
+ try {
263
+ const result = await tool.handler({ ...ctx, signal }, args ?? {})
264
+ if (mutates && signal?.aborted) {
265
+ write(
266
+ `${tool.name}: the request was cancelled after the change had started, and it ` +
267
+ `completed: ${result?.content?.[0]?.text ?? ''}`,
268
+ )
269
+ }
270
+ return result
271
+ } catch (err) {
272
+ if (!mutates && (err?.name === 'AbortError' || signal?.aborted)) {
273
+ return toolFailure(new ToolError('cancelled', 'Cancelled.'))
274
+ }
275
+ if (err?.name !== 'ToolError') {
276
+ write(`${tool.name} failed:`, err?.stack ?? String(err))
277
+ }
278
+ if (mutates && signal?.aborted) {
279
+ write(
280
+ `${tool.name}: the request was cancelled; outcome: [${err?.code ?? 'error'}] ` +
281
+ `${err?.message ?? String(err)}`,
282
+ )
283
+ }
284
+ return toolFailure(err)
285
+ }
286
+ }
287
+
288
+ export function createServer(ctx) {
289
+ const server = new McpServer(
290
+ { name: 'shieldfive-mcp', version: VERSION },
291
+ {
292
+ instructions:
293
+ 'Local file management for the directories the user allowed at startup. ' +
294
+ 'This server has no ShieldFive credential and makes no network calls, so it ' +
295
+ 'cannot see, list or verify anything in a ShieldFive vault. Do not tell the ' +
296
+ 'user a local file is backed up: this server cannot know that, and guessing ' +
297
+ 'from a filename and size is how the only copy of something gets deleted. ' +
298
+ 'Mutating tools do nothing until called with confirm: true — show the user ' +
299
+ 'the plan first, then pass back the plan_token that preview returned. A ' +
300
+ 'confirmed call without it, or after the files have changed, is refused.',
301
+ },
302
+ )
303
+
304
+ for (const tool of TOOLS) {
305
+ server.registerTool(
306
+ tool.name,
307
+ {
308
+ title: tool.title,
309
+ description: tool.description,
310
+ inputSchema: tool.inputSchema,
311
+ annotations: tool.annotations,
312
+ },
313
+ (args, extra) => runTool(tool, ctx, args, extra),
314
+ )
315
+ }
316
+
317
+ return server
318
+ }
319
+
320
+ export async function main(argv = process.argv.slice(2), env = process.env) {
321
+ const { roots, rejected } = await resolveRoots(rootCandidatesFrom(argv, env))
322
+
323
+ for (const r of rejected) log(`ignoring root ${r.path}: ${r.reason}`)
324
+ if (roots.length) {
325
+ log(`serving ${roots.length} root(s):`, roots.map((r) => r.realPath).join(', '))
326
+ } else {
327
+ log('NO ROOTS CONFIGURED — every tool will refuse.')
328
+ log(NO_ROOTS_MESSAGE)
329
+ }
330
+
331
+ const now = () => Date.now()
332
+ const ctx = { roots, noRootsMessage: NO_ROOTS_MESSAGE, now, plans: createPlanStore({ now }) }
333
+ const server = createServer(ctx)
334
+ await server.connect(new StdioServerTransport())
335
+ log('ready on stdio.')
336
+ return server
337
+ }
338
+
339
+ /**
340
+ * Is this module the program, rather than something someone imported?
341
+ *
342
+ * Both sides must be realpath'd. npm installs the `bin` as a SYMLINK — argv[1]
343
+ * is `node_modules/.bin/shieldfive-mcp` while `import.meta.url` is the resolved
344
+ * `node_modules/@shieldfive/mcp/src/server.mjs`. Comparing them unresolved is
345
+ * false for every installed copy, so the server exits silently the moment it is
346
+ * run the way an actual user runs it. `import.meta.main` would avoid this but
347
+ * is Node 24+, and this package supports 20.
348
+ */
349
+ function isMain() {
350
+ if (!process.argv[1]) return false
351
+ try {
352
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
353
+ } catch {
354
+ return false
355
+ }
356
+ }
357
+
358
+ if (isMain()) {
359
+ main().catch((err) => {
360
+ log('fatal:', err?.stack ?? String(err))
361
+ process.exit(1)
362
+ })
363
+ }