@j0hanz/fs-context-mcp 2.5.0 → 2.6.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/README.md CHANGED
@@ -15,6 +15,7 @@ The `fs-context-mcp` server provides a secure interface for language models to p
15
15
  - **Filesystem Navigation**: List directories (`ls`), visualize structures (`tree`), and list allowed roots (`roots`).
16
16
  - **File Operations**: Read (`read`, `read_many`), write (`write`), edit (`edit`), move (`mv`), and delete (`rm`) files.
17
17
  - **Advanced Search**: Find files by glob pattern (`find`) or search file contents (`grep`) with regex support.
18
+ - **Diff & Patch**: Hash files (`calculate_hash`), generate unified diffs (`diff_files`), apply patches (`apply_patch`), and bulk replace (`search_and_replace`).
18
19
  - **Batch Processing**: Efficiently read or stat multiple files in a single request (`read_many`, `stat_many`).
19
20
  - **Security**: Operations are strictly confined to allowed directories specified at startup.
20
21
 
@@ -82,7 +83,20 @@ The server is configured primarily via command-line arguments.
82
83
 
83
84
  ### Environment Variables
84
85
 
85
- Missing info. (No explicit environment variables found in configuration handling).
86
+ | Variable | Default | Description |
87
+ | :-------------------------------- | :-------------- | :---------------------------------------------------------------------- |
88
+ | `MAX_SEARCH_SIZE` | `1048576` | Max file size (bytes) for `grep`/search content. Min 100 KB, max 10 MB. |
89
+ | `MAX_FILE_SIZE` | `10485760` | Max file size (bytes) for `read`/`read_many`. Min 1 MB, max 100 MB. |
90
+ | `MAX_READ_MANY_TOTAL_SIZE` | `524288` | Max total bytes returned by `read_many`. Min 10 KB, max 100 MB. |
91
+ | `DEFAULT_SEARCH_TIMEOUT` | `5000` | Timeout (ms) for search operations. Min 100 ms, max 60 s. |
92
+ | `FS_CONTEXT_ALLOW_SENSITIVE` | `false` | Allow access to sensitive files (set to `1`/`true` to allow). |
93
+ | `FS_CONTEXT_DENYLIST` | (empty) | Additional denylist patterns (comma or newline separated). |
94
+ | `FS_CONTEXT_ALLOWLIST` | (empty) | Allowlist patterns that override the denylist. |
95
+ | `FS_CONTEXT_SEARCH_WORKERS` | `min(cores, 8)` | Worker threads for content search (0-16). |
96
+ | `FS_CONTEXT_SEARCH_WORKERS_DEBUG` | `0` | Log worker debug details when set to `1`. |
97
+ | `FS_CONTEXT_DIAGNOSTICS` | `0` | Enable diagnostics channels when set to `1`. |
98
+ | `FS_CONTEXT_DIAGNOSTICS_DETAIL` | `0` | Diagnostics detail level: 0=off, 1=hashed paths, 2=full paths. |
99
+ | `FS_CONTEXT_TOOL_LOG_ERRORS` | `0` | Emit tool error diagnostics when enabled. |
86
100
 
87
101
  ## MCP Surface
88
102
 
@@ -178,6 +192,55 @@ Search for text within file contents.
178
192
  | `isRegex` | boolean | No | `false` | Treat pattern as a regular expression. |
179
193
  | `includeHidden` | boolean | No | `false` | Include hidden files. |
180
194
 
195
+ #### `calculate_hash`
196
+
197
+ Compute a SHA-256 hash for a file.
198
+
199
+ | Parameter | Type | Required | Default | Description |
200
+ | :-------- | :----- | :------- | :------ | :--------------------- |
201
+ | `path` | string | Yes | - | Absolute path to file. |
202
+
203
+ #### `diff_files`
204
+
205
+ Generate a unified diff between two files.
206
+
207
+ | Parameter | Type | Required | Default | Description |
208
+ | :----------------- | :------ | :------- | :------ | :------------------------------------------------- |
209
+ | `original` | string | Yes | - | Path to original file. |
210
+ | `modified` | string | Yes | - | Path to modified file. |
211
+ | `context` | number | No | - | Lines of context to include in the diff. |
212
+ | `ignoreWhitespace` | boolean | No | `false` | Ignore leading/trailing whitespace in comparisons. |
213
+ | `stripTrailingCr` | boolean | No | `false` | Strip trailing carriage returns before diffing. |
214
+
215
+ #### `apply_patch`
216
+
217
+ Apply a unified patch to a file.
218
+
219
+ | Parameter | Type | Required | Default | Description |
220
+ | :----------------------- | :------ | :------- | :------ | :--------------------------------------------- |
221
+ | `path` | string | Yes | - | Path to file to patch. |
222
+ | `patch` | string | Yes | - | Unified diff content. |
223
+ | `fuzzy` | boolean | No | `false` | Allow fuzzy patching (compatibility flag). |
224
+ | `fuzzFactor` | number | No | - | Maximum fuzzy mismatches per hunk. |
225
+ | `autoConvertLineEndings` | boolean | No | `true` | Auto-convert patch line endings to match file. |
226
+ | `dryRun` | boolean | No | `false` | Check only, no writes. |
227
+
228
+ #### `search_and_replace`
229
+
230
+ Search and replace text across multiple files.
231
+
232
+ Response includes `failedFiles` and a sample `failures` list when some files cannot be processed.
233
+
234
+ | Parameter | Type | Required | Default | Description |
235
+ | :---------------- | :------ | :------- | :------ | :-------------------------------- |
236
+ | `path` | string | No | (root) | Base directory for the operation. |
237
+ | `filePattern` | string | Yes | - | Glob pattern (e.g., `**/*.ts`). |
238
+ | `excludePatterns` | array | No | `[]` | Glob patterns to exclude. |
239
+ | `searchPattern` | string | Yes | - | Text or regex pattern to replace. |
240
+ | `replacement` | string | Yes | - | Replacement text. |
241
+ | `isRegex` | boolean | No | `false` | Treat search pattern as regex. |
242
+ | `dryRun` | boolean | No | `false` | Check only, no writes. |
243
+
181
244
  #### `mkdir`
182
245
 
183
246
  Create a new directory (recursive).
@@ -231,6 +294,18 @@ Delete a file or directory.
231
294
  | `internal://instructions` | Server Instructions |
232
295
  | `fs-context://result/{id}` | Cached Tool Result |
233
296
 
297
+ Tool responses may include a `resource_link` or a `resourceUri` when output is too large to inline. Fetch the full payload with `resources/read` using the provided URI. Cached results are ephemeral and may not appear in `resources/list`.
298
+
299
+ ### Prompts
300
+
301
+ | Prompt | Description |
302
+ | :--------- | :--------------------------------------------------- |
303
+ | `get-help` | Returns the server instructions for quick reference. |
304
+
305
+ ### Tasks
306
+
307
+ Long-running tools (`grep`, `find`, `search_and_replace`) support task-augmented calls. When `task` is provided to `tools/call`, the server returns a task id that can be polled with `tasks/get` and resolved via `tasks/result`. Include `_meta.progressToken` on requests to receive `notifications/progress` updates. Task data is stored in memory and is cleared when the server restarts.
308
+
234
309
  ## Client Configuration Examples
235
310
 
236
311
  <details>
package/dist/index.js CHANGED
@@ -14,9 +14,9 @@ async function shutdown(reason, exitCode = 0) {
14
14
  shutdownStarted = true;
15
15
  process.exitCode = exitCode;
16
16
  const timer = setTimeout(() => {
17
+ console.error(`Shutdown timed out (${reason}), forcing exit.`);
17
18
  process.exit(exitCode);
18
19
  }, SHUTDOWN_TIMEOUT_MS);
19
- timer.unref();
20
20
  try {
21
21
  if (activeServer) {
22
22
  await activeServer.close();
@@ -8,7 +8,32 @@ These instructions are available as a resource (internal://instructions) or prom
8
8
 
9
9
  - Domain: Filesystem operations via an MCP server, enabling LLMs to interact with the filesystem securely and efficiently.
10
10
  - Primary Resources: Files, Directories, Search Results, File Metadata.
11
- - Tools: `ls`, `roots`, `find`, `tree`, `read`, `read_many`, `stat`, `stat_many`, `grep` (READ); `mkdir`, `write`, `edit`, `mv`, `rm` (WRITE).
11
+ - Tools: `ls`, `roots`, `find`, `tree`, `read`, `read_many`, `stat`, `stat_many`, `grep`, `calculate_hash`, `diff_files` (READ); `mkdir`, `write`, `edit`, `mv`, `rm`, `apply_patch`, `search_and_replace` (WRITE).
12
+
13
+ ---
14
+
15
+ ## PROMPTS
16
+
17
+ - `get-help`: Returns these instructions for quick recall.
18
+
19
+ ---
20
+
21
+ ## RESOURCES & RESOURCE LINKS
22
+
23
+ - `internal://instructions`: This document.
24
+ - `fs-context://result/{id}`: Cached large output (ephemeral).
25
+ - If a tool response includes a `resourceUri` or `resource_link`, call `resources/read` with the URI to fetch the full payload.
26
+
27
+ ---
28
+
29
+ ## PROGRESS & TASKS
30
+
31
+ - Include `_meta.progressToken` in requests to receive `notifications/progress` updates for long-running tools.
32
+ - Task-augmented tool calls are supported for `grep`, `find`, and `search_and_replace`:
33
+ - Send `tools/call` with `task` to get a task id.
34
+ - Poll `tasks/get` and fetch results via `tasks/result`.
35
+ - Use `tasks/cancel` to abort.
36
+ - Task data is stored in memory and cleared on restart.
12
37
 
13
38
  ---
14
39
 
@@ -64,6 +89,28 @@ These instructions are available as a resource (internal://instructions) or prom
64
89
  - Input: `path`, `head` (lines), `startLine`/`endLine`.
65
90
  - Gotcha: Large files return `resourceUri`; read it or use pagination.
66
91
 
92
+ `calculate_hash`
93
+
94
+ - Purpose: Compute a SHA-256 hash for a file.
95
+ - Input: `path` (file).
96
+
97
+ `diff_files`
98
+
99
+ - Purpose: Create a unified diff between two files.
100
+ - Input: `original`, `modified`, optional `context`, `ignoreWhitespace`, `stripTrailingCr`.
101
+ - Gotcha: Large diffs may be returned via `resourceUri`.
102
+
103
+ `apply_patch`
104
+
105
+ - Purpose: Apply a unified diff patch to a file.
106
+ - Input: `path`, `patch`, optional `fuzzy`/`fuzzFactor`, `autoConvertLineEndings`, `dryRun`.
107
+
108
+ `search_and_replace`
109
+
110
+ - Purpose: Replace text across files matching a glob.
111
+ - Input: `filePattern`, `searchPattern`, `replacement`, optional `isRegex`, `dryRun`.
112
+ - Gotcha: Review `failedFiles` and `failures` for partial errors.
113
+
67
114
  `edit`
68
115
 
69
116
  - Purpose: Sequential string replacement.
@@ -80,8 +127,3 @@ These instructions are available as a resource (internal://instructions) or prom
80
127
  - `E_INVALID_PATTERN`: Fix glob/regex syntax.
81
128
 
82
129
  ---
83
-
84
- ## RESOURCES
85
-
86
- - `internal://instructions`: This document.
87
- - `fs-context://result/{id}`: Cached large output (ephemeral).
@@ -43,7 +43,7 @@ export const PARALLEL_CONCURRENCY = getOptimalParallelism();
43
43
  export const MAX_SEARCHABLE_FILE_SIZE = parseEnvInt('MAX_SEARCH_SIZE', 1024 * 1024, 100 * 1024, 10 * 1024 * 1024);
44
44
  export const MAX_TEXT_FILE_SIZE = parseEnvInt('MAX_FILE_SIZE', 10 * 1024 * 1024, 1024 * 1024, 100 * 1024 * 1024);
45
45
  export const DEFAULT_READ_MANY_MAX_TOTAL_SIZE = parseEnvInt('MAX_READ_MANY_TOTAL_SIZE', 512 * 1024, 10 * 1024, 100 * 1024 * 1024);
46
- export const DEFAULT_SEARCH_TIMEOUT_MS = parseEnvInt('DEFAULT_SEARCH_TIMEOUT', 30000, 100, 3600000);
46
+ export const DEFAULT_SEARCH_TIMEOUT_MS = parseEnvInt('DEFAULT_SEARCH_TIMEOUT', 5000, 100, 60000);
47
47
  const ALLOW_SENSITIVE_FILES = parseEnvBool('FS_CONTEXT_ALLOW_SENSITIVE', false);
48
48
  const ENV_DENYLIST = parseEnvList('FS_CONTEXT_DENYLIST');
49
49
  const ENV_ALLOWLIST = parseEnvList('FS_CONTEXT_ALLOWLIST');
@@ -82,10 +82,19 @@ function validatePattern(pattern, options) {
82
82
  }
83
83
  }
84
84
  function buildLiteralMatcher(pattern, options) {
85
- // Optimization (RT-001): Use Regex for case-insensitive search to avoid O(N*L) allocations (toLowerCase)
86
85
  if (!options.caseSensitive) {
87
86
  const final = escapeLiteral(pattern);
88
- return buildRegexMatcher(final, false);
87
+ const regex = new RegExp(final, 'gi');
88
+ return (line) => {
89
+ regex.lastIndex = 0;
90
+ let count = 0;
91
+ while (regex.exec(line) !== null) {
92
+ count++;
93
+ if (regex.lastIndex === 0)
94
+ regex.lastIndex++;
95
+ }
96
+ return count;
97
+ };
89
98
  }
90
99
  // Fast path for case-sensitive literal
91
100
  const needle = pattern;
@@ -164,15 +173,26 @@ class ContextBuffer {
164
173
  snapshotBefore() {
165
174
  if (this.size === 0)
166
175
  return [];
176
+ const result = [];
167
177
  if (this.size < this.capacity) {
168
- return this.buffer.slice(0, this.size);
178
+ for (let i = 0; i < this.size; i++) {
179
+ const item = this.buffer[i];
180
+ if (item !== undefined)
181
+ result.push(item);
182
+ }
183
+ return result;
184
+ }
185
+ for (let i = this.head; i < this.capacity; i++) {
186
+ const item = this.buffer[i];
187
+ if (item !== undefined)
188
+ result.push(item);
189
+ }
190
+ for (let i = 0; i < this.head; i++) {
191
+ const item = this.buffer[i];
192
+ if (item !== undefined)
193
+ result.push(item);
169
194
  }
170
- // Full buffer: return ordered from oldest to newest
171
- // Oldest is at 'head', newest is at 'head - 1'
172
- return [
173
- ...this.buffer.slice(this.head, this.capacity),
174
- ...this.buffer.slice(0, this.head),
175
- ];
195
+ return result;
176
196
  }
177
197
  scheduleAfter() {
178
198
  if (this.capacity === 0)
@@ -125,6 +125,9 @@ async function collectFromStream(stream, root, gitignoreMatcher, normalized, nee
125
125
  continue;
126
126
  }
127
127
  const entryType = resolveEntryType(entry.dirent);
128
+ if (!shouldIncludeEntry(entryType, normalized)) {
129
+ continue;
130
+ }
128
131
  const isAccessible = await isEntryAccessible(entry, entryType, root, signal);
129
132
  if (!isAccessible) {
130
133
  state.skippedInaccessible++;
@@ -5,6 +5,12 @@ interface OpsTraceContext {
5
5
  path?: string | undefined;
6
6
  [key: string]: unknown;
7
7
  }
8
+ export interface ToolMetrics {
9
+ calls: number;
10
+ errors: number;
11
+ totalDurationMs: number;
12
+ }
13
+ export declare function getToolMetrics(): Record<string, ToolMetrics>;
8
14
  export declare function shouldPublishOpsTrace(): boolean;
9
15
  export declare function publishOpsTraceStart(context: OpsTraceContext): void;
10
16
  export declare function publishOpsTraceEnd(context: OpsTraceContext): void;
@@ -22,6 +22,22 @@ function parseDetail(val) {
22
22
  return 1;
23
23
  return 0;
24
24
  }
25
+ const globalMetrics = new Map();
26
+ export function getToolMetrics() {
27
+ return Object.fromEntries(globalMetrics);
28
+ }
29
+ function updateMetrics(tool, ok, durationMs) {
30
+ const current = globalMetrics.get(tool) ?? {
31
+ calls: 0,
32
+ errors: 0,
33
+ totalDurationMs: 0,
34
+ };
35
+ current.calls++;
36
+ if (!ok)
37
+ current.errors++;
38
+ current.totalDurationMs += durationMs;
39
+ globalMetrics.set(tool, current);
40
+ }
25
41
  // --- Channels & Observability State ---
26
42
  const CHANNELS = {
27
43
  tool: channel('fs-context:tool'),
@@ -245,6 +261,7 @@ async function runAndObserve(tool, run, pubTool, pubPerf, logErrors, pathVal) {
245
261
  publishPerfEnd(tool, durationMs, eluStart, loopMonitor);
246
262
  if (pubTool)
247
263
  publishToolEnd(tool, ok, durationMs, errorMsg);
264
+ updateMetrics(tool, ok, durationMs);
248
265
  if (logErrors && !ok)
249
266
  logError(tool, durationMs, errorMsg);
250
267
  }
@@ -276,8 +293,21 @@ export async function withToolDiagnostics(tool, run, options) {
276
293
  }
277
294
  const pubTool = CHANNELS.tool.hasSubscribers;
278
295
  const pubPerf = CHANNELS.perf.hasSubscribers;
279
- if (!pubTool && !pubPerf)
280
- return await run();
296
+ if (!pubTool && !pubPerf) {
297
+ const start = performance.now();
298
+ try {
299
+ const res = await run();
300
+ const duration = performance.now() - start;
301
+ const { ok } = extractOutcome(res);
302
+ updateMetrics(tool, ok, duration);
303
+ return res;
304
+ }
305
+ catch (e) {
306
+ const duration = performance.now() - start;
307
+ updateMetrics(tool, false, duration);
308
+ throw e;
309
+ }
310
+ }
281
311
  return await runAndObserve(tool, run, pubTool, pubPerf, config.logToolErrors, normalizedPath);
282
312
  });
283
313
  }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerGetHelpPrompt(server: McpServer, instructions: string): void;
@@ -0,0 +1,18 @@
1
+ export function registerGetHelpPrompt(server, instructions) {
2
+ const description = 'Return the fs-context usage instructions.';
3
+ server.registerPrompt('get-help', {
4
+ title: 'Get Help',
5
+ description,
6
+ }, () => ({
7
+ description,
8
+ messages: [
9
+ {
10
+ role: 'user',
11
+ content: {
12
+ type: 'text',
13
+ text: instructions,
14
+ },
15
+ },
16
+ ],
17
+ }));
18
+ }
package/dist/resources.js CHANGED
@@ -8,6 +8,9 @@ export function registerInstructionResource(server, instructions, iconInfo) {
8
8
  title: 'Server Instructions',
9
9
  description: 'Guidance for using the fs-context MCP tools effectively.',
10
10
  mimeType: 'text/markdown',
11
+ annotations: {
12
+ audience: ['assistant'],
13
+ },
11
14
  ...(iconInfo
12
15
  ? {
13
16
  icons: [
@@ -33,6 +36,9 @@ export function registerResultResources(server, store, iconInfo) {
33
36
  title: 'Cached Tool Result',
34
37
  description: 'Ephemeral cached tool output exposed as an MCP resource. Not guaranteed to be listed via resources/list.',
35
38
  mimeType: 'text/plain',
39
+ annotations: {
40
+ audience: ['assistant'],
41
+ },
36
42
  ...(iconInfo
37
43
  ? {
38
44
  icons: [
package/dist/schemas.d.ts CHANGED
@@ -336,4 +336,82 @@ export declare const DeleteFileOutputSchema: z.ZodObject<{
336
336
  suggestion: z.ZodOptional<z.ZodString>;
337
337
  }, z.core.$strip>>;
338
338
  }, z.core.$strip>;
339
+ export declare const CalculateHashInputSchema: z.ZodObject<{
340
+ path: z.ZodString;
341
+ }, z.core.$strict>;
342
+ export declare const CalculateHashOutputSchema: z.ZodObject<{
343
+ ok: z.ZodBoolean;
344
+ path: z.ZodOptional<z.ZodString>;
345
+ hash: z.ZodOptional<z.ZodString>;
346
+ error: z.ZodOptional<z.ZodObject<{
347
+ code: z.ZodString;
348
+ message: z.ZodString;
349
+ path: z.ZodOptional<z.ZodString>;
350
+ suggestion: z.ZodOptional<z.ZodString>;
351
+ }, z.core.$strip>>;
352
+ }, z.core.$strip>;
353
+ export declare const DiffFilesInputSchema: z.ZodObject<{
354
+ original: z.ZodString;
355
+ modified: z.ZodString;
356
+ context: z.ZodOptional<z.ZodNumber>;
357
+ ignoreWhitespace: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
358
+ stripTrailingCr: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
359
+ }, z.core.$strict>;
360
+ export declare const DiffFilesOutputSchema: z.ZodObject<{
361
+ ok: z.ZodBoolean;
362
+ diff: z.ZodOptional<z.ZodString>;
363
+ truncated: z.ZodOptional<z.ZodBoolean>;
364
+ resourceUri: z.ZodOptional<z.ZodString>;
365
+ error: z.ZodOptional<z.ZodObject<{
366
+ code: z.ZodString;
367
+ message: z.ZodString;
368
+ path: z.ZodOptional<z.ZodString>;
369
+ suggestion: z.ZodOptional<z.ZodString>;
370
+ }, z.core.$strip>>;
371
+ }, z.core.$strip>;
372
+ export declare const ApplyPatchInputSchema: z.ZodObject<{
373
+ path: z.ZodString;
374
+ patch: z.ZodString;
375
+ fuzzy: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
376
+ fuzzFactor: z.ZodOptional<z.ZodNumber>;
377
+ autoConvertLineEndings: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
378
+ dryRun: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
379
+ }, z.core.$strict>;
380
+ export declare const ApplyPatchOutputSchema: z.ZodObject<{
381
+ ok: z.ZodBoolean;
382
+ path: z.ZodOptional<z.ZodString>;
383
+ applied: z.ZodOptional<z.ZodBoolean>;
384
+ error: z.ZodOptional<z.ZodObject<{
385
+ code: z.ZodString;
386
+ message: z.ZodString;
387
+ path: z.ZodOptional<z.ZodString>;
388
+ suggestion: z.ZodOptional<z.ZodString>;
389
+ }, z.core.$strip>>;
390
+ }, z.core.$strip>;
391
+ export declare const SearchAndReplaceInputSchema: z.ZodObject<{
392
+ path: z.ZodOptional<z.ZodString>;
393
+ filePattern: z.ZodString;
394
+ excludePatterns: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodString>>>;
395
+ searchPattern: z.ZodString;
396
+ replacement: z.ZodString;
397
+ isRegex: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
398
+ dryRun: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
399
+ }, z.core.$strict>;
400
+ export declare const SearchAndReplaceOutputSchema: z.ZodObject<{
401
+ ok: z.ZodBoolean;
402
+ matches: z.ZodOptional<z.ZodNumber>;
403
+ filesChanged: z.ZodOptional<z.ZodNumber>;
404
+ failedFiles: z.ZodOptional<z.ZodNumber>;
405
+ failures: z.ZodOptional<z.ZodArray<z.ZodObject<{
406
+ path: z.ZodString;
407
+ error: z.ZodString;
408
+ }, z.core.$strip>>>;
409
+ dryRun: z.ZodOptional<z.ZodBoolean>;
410
+ error: z.ZodOptional<z.ZodObject<{
411
+ code: z.ZodString;
412
+ message: z.ZodString;
413
+ path: z.ZodOptional<z.ZodString>;
414
+ suggestion: z.ZodOptional<z.ZodString>;
415
+ }, z.core.$strip>>;
416
+ }, z.core.$strip>;
339
417
  export {};