@j0hanz/filesystem-mcp 1.0.4 → 1.1.1
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 +1 -1
- package/dist/cli.d.ts +2 -3
- package/dist/completions.d.ts +5 -1
- package/dist/completions.js +197 -33
- package/dist/index.js +1 -0
- package/dist/instructions.md +1 -1
- package/dist/lib/constants.js +24 -3
- package/dist/lib/errors.js +29 -9
- package/dist/lib/observability.d.ts +0 -6
- package/dist/lib/observability.js +45 -15
- package/dist/schemas.d.ts +1 -8
- package/dist/schemas.js +5 -1
- package/dist/tools/calculate-hash.js +58 -12
- package/dist/tools/edit-file.js +34 -2
- package/dist/tools/search-content.js +11 -1
- package/dist/tools/shared.d.ts +2 -3
- package/dist/tools/task-support.js +62 -2
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -90,7 +90,7 @@ docker run -i --rm \
|
|
|
90
90
|
|
|
91
91
|
## Configuration
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
Allowed directories can be provided via command-line arguments, via the MCP Roots protocol, or by using `--allow-cwd`.
|
|
94
94
|
|
|
95
95
|
### Arguments
|
|
96
96
|
|
package/dist/cli.d.ts
CHANGED
|
@@ -2,8 +2,7 @@ export declare class CliExitError extends Error {
|
|
|
2
2
|
readonly exitCode: number;
|
|
3
3
|
constructor(message: string, exitCode: number);
|
|
4
4
|
}
|
|
5
|
-
export
|
|
5
|
+
export declare function parseArgs(): Promise<{
|
|
6
6
|
allowedDirs: string[];
|
|
7
7
|
allowCwd: boolean;
|
|
8
|
-
}
|
|
9
|
-
export declare function parseArgs(): Promise<ParseArgsResult>;
|
|
8
|
+
}>;
|
package/dist/completions.d.ts
CHANGED
|
@@ -4,6 +4,10 @@ interface CompletionResult {
|
|
|
4
4
|
total?: number;
|
|
5
5
|
hasMore?: boolean;
|
|
6
6
|
}
|
|
7
|
-
|
|
7
|
+
interface CompletionOptions {
|
|
8
|
+
argumentName?: string;
|
|
9
|
+
contextArguments?: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
export declare function getPathCompletions(currentValue: string, options?: CompletionOptions): Promise<CompletionResult>;
|
|
8
12
|
export declare function registerCompletions(server: McpServer): void;
|
|
9
13
|
export {};
|
package/dist/completions.js
CHANGED
|
@@ -3,6 +3,103 @@ import * as path from 'node:path';
|
|
|
3
3
|
import { CompleteRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
4
|
import { getAllowedDirectories, isPathWithinDirectories, normalizePath, } from './lib/path-validation.js';
|
|
5
5
|
const MAX_COMPLETION_ITEMS = 100;
|
|
6
|
+
const PATH_ARGUMENTS = new Set([
|
|
7
|
+
'path',
|
|
8
|
+
'source',
|
|
9
|
+
'destination',
|
|
10
|
+
'original',
|
|
11
|
+
'modified',
|
|
12
|
+
'directory',
|
|
13
|
+
'file',
|
|
14
|
+
'root',
|
|
15
|
+
'cwd',
|
|
16
|
+
]);
|
|
17
|
+
function isRecord(value) {
|
|
18
|
+
return typeof value === 'object' && value !== null;
|
|
19
|
+
}
|
|
20
|
+
function isPathLikeArgumentName(argName) {
|
|
21
|
+
return (PATH_ARGUMENTS.has(argName) ||
|
|
22
|
+
argName.endsWith('paths') ||
|
|
23
|
+
argName.endsWith('path') ||
|
|
24
|
+
argName.endsWith('files') ||
|
|
25
|
+
argName.endsWith('file') ||
|
|
26
|
+
argName.endsWith('dirs') ||
|
|
27
|
+
argName.endsWith('dir'));
|
|
28
|
+
}
|
|
29
|
+
function parseResourceReference(value) {
|
|
30
|
+
if (!isRecord(value))
|
|
31
|
+
return undefined;
|
|
32
|
+
if (value['type'] !== 'ref/resource')
|
|
33
|
+
return undefined;
|
|
34
|
+
const { uri } = value;
|
|
35
|
+
if (typeof uri !== 'string')
|
|
36
|
+
return undefined;
|
|
37
|
+
return { type: 'ref/resource', uri };
|
|
38
|
+
}
|
|
39
|
+
function extractTemplateVariables(uri) {
|
|
40
|
+
const vars = [];
|
|
41
|
+
const isVariableChar = (char) => {
|
|
42
|
+
const code = char.charCodeAt(0);
|
|
43
|
+
const isDigit = code >= 48 && code <= 57;
|
|
44
|
+
const isUpper = code >= 65 && code <= 90;
|
|
45
|
+
const isLower = code >= 97 && code <= 122;
|
|
46
|
+
return isDigit || isUpper || isLower || code === 95;
|
|
47
|
+
};
|
|
48
|
+
let index = 0;
|
|
49
|
+
while (index < uri.length) {
|
|
50
|
+
const start = uri.indexOf('{', index);
|
|
51
|
+
if (start === -1)
|
|
52
|
+
break;
|
|
53
|
+
const end = uri.indexOf('}', start + 1);
|
|
54
|
+
if (end === -1)
|
|
55
|
+
break;
|
|
56
|
+
const raw = uri.slice(start + 1, end);
|
|
57
|
+
let normalized = '';
|
|
58
|
+
for (const char of raw) {
|
|
59
|
+
if (isVariableChar(char)) {
|
|
60
|
+
normalized += char.toLowerCase();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (normalized.length > 0)
|
|
64
|
+
vars.push(normalized);
|
|
65
|
+
index = end + 1;
|
|
66
|
+
}
|
|
67
|
+
return vars;
|
|
68
|
+
}
|
|
69
|
+
function isPathArgumentFromReference(argumentName, ref) {
|
|
70
|
+
const resourceRef = parseResourceReference(ref);
|
|
71
|
+
if (!resourceRef)
|
|
72
|
+
return false;
|
|
73
|
+
const normalizedArg = argumentName.toLowerCase();
|
|
74
|
+
const templateVars = extractTemplateVariables(resourceRef.uri);
|
|
75
|
+
if (templateVars.length === 0)
|
|
76
|
+
return false;
|
|
77
|
+
const matchesVariable = templateVars.includes(normalizedArg);
|
|
78
|
+
if (!matchesVariable)
|
|
79
|
+
return false;
|
|
80
|
+
if (isPathLikeArgumentName(normalizedArg))
|
|
81
|
+
return true;
|
|
82
|
+
if (resourceRef.uri.toLowerCase().includes('file:///'))
|
|
83
|
+
return true;
|
|
84
|
+
const uriLooksPathLike = resourceRef.uri.includes('/') &&
|
|
85
|
+
(resourceRef.uri.toLowerCase().includes('path') ||
|
|
86
|
+
resourceRef.uri.toLowerCase().includes('file') ||
|
|
87
|
+
resourceRef.uri.toLowerCase().includes('dir') ||
|
|
88
|
+
resourceRef.uri.toLowerCase().includes('root') ||
|
|
89
|
+
resourceRef.uri.toLowerCase().includes('cwd'));
|
|
90
|
+
return uriLooksPathLike;
|
|
91
|
+
}
|
|
92
|
+
function extractContextArguments(value) {
|
|
93
|
+
if (!isRecord(value))
|
|
94
|
+
return undefined;
|
|
95
|
+
const context = value['arguments'];
|
|
96
|
+
if (!isRecord(context))
|
|
97
|
+
return undefined;
|
|
98
|
+
const entries = Object.entries(context).filter((entry) => typeof entry[1] === 'string');
|
|
99
|
+
if (entries.length === 0)
|
|
100
|
+
return undefined;
|
|
101
|
+
return Object.fromEntries(entries.map(([key, val]) => [key.toLowerCase(), val]));
|
|
102
|
+
}
|
|
6
103
|
function hasTrailingSeparator(value) {
|
|
7
104
|
return (value.endsWith(path.sep) || value.endsWith('/') || value.endsWith('\\'));
|
|
8
105
|
}
|
|
@@ -33,18 +130,97 @@ function resolveNamedRootContext(currentValue, allowed) {
|
|
|
33
130
|
const remainder = rest.join(path.sep);
|
|
34
131
|
return resolveFromBase(root, remainder, trailingSeparator);
|
|
35
132
|
}
|
|
36
|
-
function
|
|
133
|
+
function resolveNamedRootPath(value, allowed) {
|
|
134
|
+
const normalizedInput = value.replace(/\\/gu, '/');
|
|
135
|
+
const [rootName, ...rest] = normalizedInput.split('/');
|
|
136
|
+
if (!rootName)
|
|
137
|
+
return undefined;
|
|
138
|
+
const root = allowed.find((candidate) => path.basename(candidate).toLowerCase() === rootName.toLowerCase());
|
|
139
|
+
if (!root)
|
|
140
|
+
return undefined;
|
|
141
|
+
const remainder = rest.join(path.sep);
|
|
142
|
+
return normalizePath(path.resolve(root, remainder));
|
|
143
|
+
}
|
|
144
|
+
function chooseContextKeys(argumentName) {
|
|
145
|
+
const normalized = argumentName.toLowerCase();
|
|
146
|
+
if (normalized === 'destination') {
|
|
147
|
+
return ['source', 'path', 'cwd', 'root'];
|
|
148
|
+
}
|
|
149
|
+
if (normalized === 'path' ||
|
|
150
|
+
normalized === 'source' ||
|
|
151
|
+
normalized === 'original' ||
|
|
152
|
+
normalized === 'modified' ||
|
|
153
|
+
normalized === 'file') {
|
|
154
|
+
return ['path', 'cwd', 'root'];
|
|
155
|
+
}
|
|
156
|
+
return ['path', 'source', 'cwd', 'root'];
|
|
157
|
+
}
|
|
158
|
+
function resolveContextCandidatePath(candidate, allowed) {
|
|
159
|
+
if (isAbsolutePathInput(candidate)) {
|
|
160
|
+
return normalizePath(candidate);
|
|
161
|
+
}
|
|
162
|
+
if (allowed.length === 1) {
|
|
163
|
+
const base = allowed[0];
|
|
164
|
+
if (!base)
|
|
165
|
+
return undefined;
|
|
166
|
+
return normalizePath(path.resolve(base, candidate));
|
|
167
|
+
}
|
|
168
|
+
return resolveNamedRootPath(candidate, allowed);
|
|
169
|
+
}
|
|
170
|
+
async function toAllowedContextDirectory(resolved, allowed) {
|
|
171
|
+
if (!isPathWithinDirectories(resolved, allowed))
|
|
172
|
+
return undefined;
|
|
173
|
+
try {
|
|
174
|
+
const stats = await fs.stat(resolved);
|
|
175
|
+
if (stats.isDirectory())
|
|
176
|
+
return resolved;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// Fall back to parent path best-effort resolution.
|
|
180
|
+
}
|
|
181
|
+
const parent = path.dirname(resolved);
|
|
182
|
+
return isPathWithinDirectories(parent, allowed) ? parent : undefined;
|
|
183
|
+
}
|
|
184
|
+
async function resolveContextBaseDirectory(argumentName, contextArguments, allowed) {
|
|
185
|
+
if (!contextArguments || Object.keys(contextArguments).length === 0) {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
const keys = chooseContextKeys(argumentName);
|
|
189
|
+
for (const key of keys) {
|
|
190
|
+
const candidate = contextArguments[key];
|
|
191
|
+
if (!candidate || candidate.trim().length === 0)
|
|
192
|
+
continue;
|
|
193
|
+
const resolved = resolveContextCandidatePath(candidate, allowed);
|
|
194
|
+
if (!resolved)
|
|
195
|
+
continue;
|
|
196
|
+
const baseDirectory = await toAllowedContextDirectory(resolved, allowed);
|
|
197
|
+
if (baseDirectory)
|
|
198
|
+
return baseDirectory;
|
|
199
|
+
}
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
function getSearchContext(currentValue, allowed, contextBase) {
|
|
37
203
|
const trailingSeparator = hasTrailingSeparator(currentValue);
|
|
38
204
|
if (isAbsolutePathInput(currentValue)) {
|
|
39
205
|
return resolveFromBase(path.parse(currentValue).root || path.sep, currentValue, trailingSeparator);
|
|
40
206
|
}
|
|
207
|
+
const namedRootContext = resolveNamedRootContext(currentValue, allowed);
|
|
208
|
+
if (namedRootContext) {
|
|
209
|
+
return namedRootContext;
|
|
210
|
+
}
|
|
211
|
+
if (contextBase) {
|
|
212
|
+
if (currentValue.length === 0) {
|
|
213
|
+
return { searchDir: contextBase, prefix: '' };
|
|
214
|
+
}
|
|
215
|
+
return resolveFromBase(contextBase, currentValue, trailingSeparator);
|
|
216
|
+
}
|
|
41
217
|
if (allowed.length === 1) {
|
|
42
218
|
const base = allowed[0];
|
|
43
219
|
if (base) {
|
|
44
220
|
return resolveFromBase(base, currentValue, trailingSeparator);
|
|
45
221
|
}
|
|
46
222
|
}
|
|
47
|
-
return
|
|
223
|
+
return undefined;
|
|
48
224
|
}
|
|
49
225
|
async function findMatchesInDirectory(searchDir, prefix, allowed) {
|
|
50
226
|
const matches = [];
|
|
@@ -92,18 +268,19 @@ function findMatchingRoots(searchDir, prefix, allowed) {
|
|
|
92
268
|
}
|
|
93
269
|
return matches;
|
|
94
270
|
}
|
|
95
|
-
export async function getPathCompletions(currentValue) {
|
|
271
|
+
export async function getPathCompletions(currentValue, options = {}) {
|
|
96
272
|
const allowed = getAllowedDirectories();
|
|
97
|
-
// If empty, suggest allowed roots
|
|
98
|
-
if (!currentValue) {
|
|
99
|
-
return {
|
|
100
|
-
values: allowed,
|
|
101
|
-
total: allowed.length,
|
|
102
|
-
hasMore: false,
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
273
|
try {
|
|
106
|
-
const
|
|
274
|
+
const contextBase = await resolveContextBaseDirectory(options.argumentName ?? '', options.contextArguments, allowed);
|
|
275
|
+
// If no value and no context base, suggest roots.
|
|
276
|
+
if (!currentValue && !contextBase) {
|
|
277
|
+
return {
|
|
278
|
+
values: allowed,
|
|
279
|
+
total: allowed.length,
|
|
280
|
+
hasMore: false,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const context = getSearchContext(currentValue, allowed, contextBase);
|
|
107
284
|
if (!context) {
|
|
108
285
|
const rootMatches = findRootPrefixMatches(currentValue, allowed);
|
|
109
286
|
const sliced = rootMatches.slice(0, MAX_COMPLETION_ITEMS);
|
|
@@ -143,32 +320,19 @@ export async function getPathCompletions(currentValue) {
|
|
|
143
320
|
export function registerCompletions(server) {
|
|
144
321
|
server.server.setRequestHandler(CompleteRequestSchema, async (request) => {
|
|
145
322
|
const { params } = request;
|
|
146
|
-
const { argument } = params;
|
|
147
|
-
const pathArguments = new Set([
|
|
148
|
-
'path',
|
|
149
|
-
'source',
|
|
150
|
-
'destination',
|
|
151
|
-
'original',
|
|
152
|
-
'modified',
|
|
153
|
-
'directory',
|
|
154
|
-
'file',
|
|
155
|
-
'root',
|
|
156
|
-
'cwd',
|
|
157
|
-
]);
|
|
158
|
-
// Check if argument name is relevant or ends with path-like suffixes
|
|
323
|
+
const { argument, ref } = params;
|
|
159
324
|
const argName = argument.name.toLowerCase();
|
|
160
|
-
const isPathArg =
|
|
161
|
-
argName
|
|
162
|
-
argName.endsWith('path') ||
|
|
163
|
-
argName.endsWith('files') ||
|
|
164
|
-
argName.endsWith('file') ||
|
|
165
|
-
argName.endsWith('dirs') ||
|
|
166
|
-
argName.endsWith('dir');
|
|
325
|
+
const isPathArg = isPathLikeArgumentName(argName) ||
|
|
326
|
+
isPathArgumentFromReference(argName, ref);
|
|
167
327
|
if (!isPathArg) {
|
|
168
328
|
return { completion: { values: [], total: 0, hasMore: false } };
|
|
169
329
|
}
|
|
330
|
+
const contextArguments = extractContextArguments(params.context);
|
|
170
331
|
const { value } = argument;
|
|
171
|
-
const completions = await getPathCompletions(value
|
|
332
|
+
const completions = await getPathCompletions(value, {
|
|
333
|
+
argumentName: argName,
|
|
334
|
+
...(contextArguments ? { contextArguments } : {}),
|
|
335
|
+
});
|
|
172
336
|
return {
|
|
173
337
|
completion: {
|
|
174
338
|
values: completions.values,
|
package/dist/index.js
CHANGED
package/dist/instructions.md
CHANGED
|
@@ -29,7 +29,7 @@ These instructions are available as a resource (internal://instructions) or prom
|
|
|
29
29
|
## PROGRESS & TASKS
|
|
30
30
|
|
|
31
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`, `search_and_replace`, `tree`, `read_many`, and `stat_many`:
|
|
32
|
+
- Task-augmented tool calls are supported for `grep`, `find`, `calculate_hash`, `search_and_replace`, `tree`, `read_many`, and `stat_many`:
|
|
33
33
|
- These tools declare `execution.taskSupport: "optional"` — invoke normally or as a task.
|
|
34
34
|
- Send `tools/call` with `task` to get a task id.
|
|
35
35
|
- Poll `tasks/get` and fetch results via `tasks/result`.
|
package/dist/lib/constants.js
CHANGED
|
@@ -33,9 +33,30 @@ function parseEnvList(envVar) {
|
|
|
33
33
|
.filter((entry) => entry.length > 0);
|
|
34
34
|
}
|
|
35
35
|
// Auto-tuned parallelism based on CPU cores (no env override)
|
|
36
|
+
const BYTES_PER_PARALLEL_TASK = 64 * 1024 * 1024;
|
|
37
|
+
const BYTES_PER_SEARCH_WORKER = 128 * 1024 * 1024;
|
|
38
|
+
function getAvailableMemory() {
|
|
39
|
+
if (typeof process.availableMemory !== 'function')
|
|
40
|
+
return undefined;
|
|
41
|
+
const available = process.availableMemory();
|
|
42
|
+
if (!Number.isFinite(available) || available <= 0)
|
|
43
|
+
return undefined;
|
|
44
|
+
return available;
|
|
45
|
+
}
|
|
46
|
+
function applyMemoryBound(cpuBound, bytesPerUnit, minValue) {
|
|
47
|
+
const availableMemory = getAvailableMemory();
|
|
48
|
+
if (availableMemory === undefined)
|
|
49
|
+
return cpuBound;
|
|
50
|
+
const memoryBound = Math.floor(availableMemory / bytesPerUnit);
|
|
51
|
+
return Math.min(cpuBound, Math.max(memoryBound, minValue));
|
|
52
|
+
}
|
|
36
53
|
function getOptimalParallelism() {
|
|
37
|
-
const
|
|
38
|
-
return
|
|
54
|
+
const cpuBound = Math.min(Math.max(availableParallelism(), 4), 32);
|
|
55
|
+
return applyMemoryBound(cpuBound, BYTES_PER_PARALLEL_TASK, 2);
|
|
56
|
+
}
|
|
57
|
+
function getDefaultSearchWorkers() {
|
|
58
|
+
const cpuBound = Math.min(availableParallelism(), 8);
|
|
59
|
+
return applyMemoryBound(cpuBound, BYTES_PER_SEARCH_WORKER, 1);
|
|
39
60
|
}
|
|
40
61
|
// Hardcoded optimal values (no env override needed)
|
|
41
62
|
export const PARALLEL_CONCURRENCY = getOptimalParallelism();
|
|
@@ -52,7 +73,7 @@ const ENV_ALLOWLIST = parseEnvList('FS_CONTEXT_ALLOWLIST');
|
|
|
52
73
|
* Default: CPU cores (capped at 8 for optimal I/O performance).
|
|
53
74
|
* Configurable via FS_CONTEXT_SEARCH_WORKERS env var.
|
|
54
75
|
*/
|
|
55
|
-
export const SEARCH_WORKERS = parseEnvInt('FS_CONTEXT_SEARCH_WORKERS',
|
|
76
|
+
export const SEARCH_WORKERS = parseEnvInt('FS_CONTEXT_SEARCH_WORKERS', getDefaultSearchWorkers(), 0, 16);
|
|
56
77
|
// Hardcoded defaults
|
|
57
78
|
export const DEFAULT_MAX_DEPTH = 10;
|
|
58
79
|
export const DEFAULT_LIST_MAX_ENTRIES = 10000;
|
package/dist/lib/errors.js
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { constants as osConstants } from 'node:os';
|
|
2
|
-
import { getSystemErrorName, inspect } from 'node:util';
|
|
2
|
+
import { getSystemErrorMap, getSystemErrorName, inspect } from 'node:util';
|
|
3
3
|
import { ErrorCode, joinLines } from '../config.js';
|
|
4
4
|
export { ErrorCode };
|
|
5
|
+
function isNativeError(error) {
|
|
6
|
+
const candidate = Error;
|
|
7
|
+
if (typeof candidate.isError === 'function') {
|
|
8
|
+
return candidate.isError(error);
|
|
9
|
+
}
|
|
10
|
+
return error instanceof Error;
|
|
11
|
+
}
|
|
5
12
|
export function isNodeError(error) {
|
|
6
|
-
if (!(error
|
|
13
|
+
if (!isNativeError(error))
|
|
7
14
|
return false;
|
|
8
15
|
if (!('code' in error))
|
|
9
16
|
return false;
|
|
@@ -11,7 +18,7 @@ export function isNodeError(error) {
|
|
|
11
18
|
return typeof code === 'string';
|
|
12
19
|
}
|
|
13
20
|
function getNodeErrno(error) {
|
|
14
|
-
if (!(error
|
|
21
|
+
if (!isNativeError(error))
|
|
15
22
|
return undefined;
|
|
16
23
|
if (!('errno' in error))
|
|
17
24
|
return undefined;
|
|
@@ -21,6 +28,7 @@ function getNodeErrno(error) {
|
|
|
21
28
|
return errno;
|
|
22
29
|
}
|
|
23
30
|
const ERRNO_CODE_BY_VALUE = new Map();
|
|
31
|
+
const SYSTEM_ERROR_MAP = getSystemErrorMap();
|
|
24
32
|
for (const [name, value] of Object.entries(osConstants.errno)) {
|
|
25
33
|
if (typeof value !== 'number')
|
|
26
34
|
continue;
|
|
@@ -29,6 +37,15 @@ for (const [name, value] of Object.entries(osConstants.errno)) {
|
|
|
29
37
|
}
|
|
30
38
|
}
|
|
31
39
|
const ERROR_CODE_RE = /^[A-Z][A-Z0-9_]+$/u;
|
|
40
|
+
function getSystemErrorNameFromMap(errno) {
|
|
41
|
+
const direct = SYSTEM_ERROR_MAP.get(errno);
|
|
42
|
+
if (direct)
|
|
43
|
+
return direct[0];
|
|
44
|
+
const normalized = SYSTEM_ERROR_MAP.get(-Math.abs(errno));
|
|
45
|
+
if (normalized)
|
|
46
|
+
return normalized[0];
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
32
49
|
function getNodeErrorCodeFromErrno(errno) {
|
|
33
50
|
const direct = ERRNO_CODE_BY_VALUE.get(errno);
|
|
34
51
|
if (direct)
|
|
@@ -36,8 +53,11 @@ function getNodeErrorCodeFromErrno(errno) {
|
|
|
36
53
|
const normalized = ERRNO_CODE_BY_VALUE.get(Math.abs(errno));
|
|
37
54
|
if (normalized)
|
|
38
55
|
return normalized;
|
|
56
|
+
const fromMap = getSystemErrorNameFromMap(errno);
|
|
57
|
+
if (fromMap && ERROR_CODE_RE.test(fromMap))
|
|
58
|
+
return fromMap;
|
|
39
59
|
try {
|
|
40
|
-
const fromSystem = getSystemErrorName(errno);
|
|
60
|
+
const fromSystem = getSystemErrorName(errno <= 0 ? errno : -errno);
|
|
41
61
|
return ERROR_CODE_RE.test(fromSystem) ? fromSystem : undefined;
|
|
42
62
|
}
|
|
43
63
|
catch {
|
|
@@ -55,7 +75,7 @@ function getNodeErrorCodeLabel(error) {
|
|
|
55
75
|
export function formatUnknownErrorMessage(error) {
|
|
56
76
|
if (typeof error === 'string')
|
|
57
77
|
return error;
|
|
58
|
-
if (error
|
|
78
|
+
if (isNativeError(error))
|
|
59
79
|
return error.message;
|
|
60
80
|
try {
|
|
61
81
|
return inspect(error, {
|
|
@@ -99,7 +119,7 @@ function walkErrorChain(error, visitor) {
|
|
|
99
119
|
while (current !== undefined && current !== null && !visited.has(current)) {
|
|
100
120
|
if (visitor(current))
|
|
101
121
|
return true;
|
|
102
|
-
if (!(current
|
|
122
|
+
if (!isNativeError(current))
|
|
103
123
|
break;
|
|
104
124
|
visited.add(current);
|
|
105
125
|
const next = current.cause;
|
|
@@ -108,7 +128,7 @@ function walkErrorChain(error, visitor) {
|
|
|
108
128
|
return false;
|
|
109
129
|
}
|
|
110
130
|
function isAbortErrorSingle(error) {
|
|
111
|
-
if (!(error
|
|
131
|
+
if (!isNativeError(error))
|
|
112
132
|
return false;
|
|
113
133
|
if (error.name === 'AbortError')
|
|
114
134
|
return true;
|
|
@@ -119,7 +139,7 @@ export function isAbortError(error) {
|
|
|
119
139
|
return walkErrorChain(error, isAbortErrorSingle);
|
|
120
140
|
}
|
|
121
141
|
function isTimeoutErrorSingle(error) {
|
|
122
|
-
if (!(error
|
|
142
|
+
if (!isNativeError(error))
|
|
123
143
|
return false;
|
|
124
144
|
if (error.name === 'TimeoutError')
|
|
125
145
|
return true;
|
|
@@ -178,7 +198,7 @@ function getDirectErrorCode(error) {
|
|
|
178
198
|
return undefined;
|
|
179
199
|
}
|
|
180
200
|
function classifyMessageError(error) {
|
|
181
|
-
const message = error
|
|
201
|
+
const message = isNativeError(error) ? error.message : String(error);
|
|
182
202
|
const lower = message.toLowerCase();
|
|
183
203
|
if (lower.includes('enoent') || lower.includes('no such file or directory')) {
|
|
184
204
|
return ErrorCode.E_NOT_FOUND;
|
|
@@ -5,12 +5,6 @@ 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>;
|
|
14
8
|
export declare function shouldPublishOpsTrace(): boolean;
|
|
15
9
|
export declare function publishOpsTraceStart(context: OpsTraceContext): void;
|
|
16
10
|
export declare function publishOpsTraceEnd(context: OpsTraceContext): void;
|
|
@@ -23,9 +23,6 @@ function parseDetail(val) {
|
|
|
23
23
|
return 0;
|
|
24
24
|
}
|
|
25
25
|
const globalMetrics = new Map();
|
|
26
|
-
export function getToolMetrics() {
|
|
27
|
-
return Object.fromEntries(globalMetrics);
|
|
28
|
-
}
|
|
29
26
|
function updateMetrics(tool, ok, durationMs) {
|
|
30
27
|
const current = globalMetrics.get(tool) ?? {
|
|
31
28
|
calls: 0,
|
|
@@ -146,11 +143,23 @@ function getDelayStats(h) {
|
|
|
146
143
|
exceeds: h.exceeds,
|
|
147
144
|
};
|
|
148
145
|
}
|
|
146
|
+
function clearPublishedMeasures(entries) {
|
|
147
|
+
if (entries.length === 0)
|
|
148
|
+
return;
|
|
149
|
+
const names = new Set();
|
|
150
|
+
for (const entry of entries) {
|
|
151
|
+
names.add(entry.name);
|
|
152
|
+
}
|
|
153
|
+
for (const name of names) {
|
|
154
|
+
performance.clearMeasures(name);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
149
157
|
function ensureObserver() {
|
|
150
158
|
if (perfObserver)
|
|
151
159
|
return;
|
|
152
160
|
perfObserver = new PerformanceObserver((list) => {
|
|
153
|
-
|
|
161
|
+
const entries = list.getEntries();
|
|
162
|
+
for (const entry of entries) {
|
|
154
163
|
CHANNELS.perf.publish({
|
|
155
164
|
phase: 'measure',
|
|
156
165
|
name: entry.name,
|
|
@@ -158,6 +167,13 @@ function ensureObserver() {
|
|
|
158
167
|
detail: entry.detail,
|
|
159
168
|
});
|
|
160
169
|
}
|
|
170
|
+
try {
|
|
171
|
+
// Keep the global timeline bounded while preserving published events.
|
|
172
|
+
clearPublishedMeasures(entries);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Never allow observability cleanup to affect tool execution.
|
|
176
|
+
}
|
|
161
177
|
});
|
|
162
178
|
perfObserver.observe({ entryTypes: ['measure'] });
|
|
163
179
|
}
|
|
@@ -208,22 +224,36 @@ export function startPerfMeasure(name, detail) {
|
|
|
208
224
|
const startMark = `${name}:start:${id}`;
|
|
209
225
|
const endMark = `${name}:end:${id}`;
|
|
210
226
|
const runInCapturedContext = AsyncLocalStorage.snapshot();
|
|
227
|
+
let finished = false;
|
|
211
228
|
performance.mark(startMark);
|
|
212
229
|
return (ok) => {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
230
|
+
if (finished)
|
|
231
|
+
return;
|
|
232
|
+
finished = true;
|
|
233
|
+
try {
|
|
234
|
+
runInCapturedContext(() => {
|
|
235
|
+
try {
|
|
236
|
+
performance.mark(endMark);
|
|
237
|
+
let meta = enrichWithToolContext(detail);
|
|
238
|
+
if (ok !== undefined) {
|
|
239
|
+
meta = { ...(meta ?? {}), ok };
|
|
240
|
+
}
|
|
241
|
+
performance.measure(name, {
|
|
242
|
+
start: startMark,
|
|
243
|
+
end: endMark,
|
|
244
|
+
detail: meta,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
performance.clearMarks(startMark);
|
|
249
|
+
performance.clearMarks(endMark);
|
|
250
|
+
}
|
|
223
251
|
});
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
224
254
|
performance.clearMarks(startMark);
|
|
225
255
|
performance.clearMarks(endMark);
|
|
226
|
-
}
|
|
256
|
+
}
|
|
227
257
|
};
|
|
228
258
|
}
|
|
229
259
|
function publishToolStart(tool, pathVal) {
|
package/dist/schemas.d.ts
CHANGED
|
@@ -11,14 +11,6 @@ interface TreeEntry {
|
|
|
11
11
|
relativePath: string;
|
|
12
12
|
children?: TreeEntry[] | undefined;
|
|
13
13
|
}
|
|
14
|
-
export declare const ErrorSchema: z.ZodObject<{
|
|
15
|
-
code: z.ZodEnum<{
|
|
16
|
-
[x: string]: string;
|
|
17
|
-
}>;
|
|
18
|
-
message: z.ZodString;
|
|
19
|
-
path: z.ZodOptional<z.ZodString>;
|
|
20
|
-
suggestion: z.ZodOptional<z.ZodString>;
|
|
21
|
-
}, z.core.$strict>;
|
|
22
14
|
export declare const ToolErrorResponseSchema: z.ZodObject<{
|
|
23
15
|
ok: z.ZodLiteral<false>;
|
|
24
16
|
error: z.ZodObject<{
|
|
@@ -411,6 +403,7 @@ export declare const EditFileOutputSchema: z.ZodObject<{
|
|
|
411
403
|
ok: z.ZodBoolean;
|
|
412
404
|
path: z.ZodOptional<z.ZodString>;
|
|
413
405
|
appliedEdits: z.ZodOptional<z.ZodNumber>;
|
|
406
|
+
lineRange: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber], null>>;
|
|
414
407
|
unmatchedEdits: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
415
408
|
error: z.ZodOptional<z.ZodObject<{
|
|
416
409
|
code: z.ZodEnum<{
|
package/dist/schemas.js
CHANGED
|
@@ -43,7 +43,7 @@ const TreeEntrySchema = z.lazy(() => z.strictObject({
|
|
|
43
43
|
relativePath: z.string().describe('Relative path'),
|
|
44
44
|
children: z.array(TreeEntrySchema).optional().describe('Children'),
|
|
45
45
|
}));
|
|
46
|
-
|
|
46
|
+
const ErrorSchema = z.strictObject({
|
|
47
47
|
code: z
|
|
48
48
|
.enum(Object.values(ErrorCode))
|
|
49
49
|
.describe('Error code (e.g. E_NOT_FOUND)'),
|
|
@@ -510,6 +510,10 @@ export const EditFileOutputSchema = z.object({
|
|
|
510
510
|
ok: z.boolean(),
|
|
511
511
|
path: z.string().optional(),
|
|
512
512
|
appliedEdits: z.number().optional(),
|
|
513
|
+
lineRange: z
|
|
514
|
+
.tuple([z.number(), z.number()])
|
|
515
|
+
.optional()
|
|
516
|
+
.describe('Line range modified [start, end] (1-based)'),
|
|
513
517
|
unmatchedEdits: z
|
|
514
518
|
.array(z.string())
|
|
515
519
|
.optional()
|
|
@@ -3,12 +3,14 @@ import * as path from 'node:path';
|
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { createReadStream } from 'node:fs';
|
|
5
5
|
import { ErrorCode } from '../lib/errors.js';
|
|
6
|
+
import { isIgnoredByGitignore, loadRootGitignore, } from '../lib/file-operations/gitignore.js';
|
|
6
7
|
import { globEntries } from '../lib/file-operations/glob-engine.js';
|
|
7
8
|
import { assertNotAborted, createTimedAbortSignal, withAbort, } from '../lib/fs-helpers.js';
|
|
8
9
|
import { withToolDiagnostics } from '../lib/observability.js';
|
|
9
10
|
import { validateExistingPath } from '../lib/path-validation.js';
|
|
10
11
|
import { CalculateHashInputSchema, CalculateHashOutputSchema, } from '../schemas.js';
|
|
11
|
-
import { buildToolErrorResponse, buildToolResponse, withDefaultIcons, withToolErrorHandling, wrapToolHandler, } from './shared.js';
|
|
12
|
+
import { buildToolErrorResponse, buildToolResponse, createProgressReporter, getExperimentalTaskRegistration, notifyProgress, withDefaultIcons, withToolErrorHandling, wrapToolHandler, } from './shared.js';
|
|
13
|
+
import { createToolTaskHandler } from './task-support.js';
|
|
12
14
|
const WINDOWS_PATH_SEPARATOR = /\\/gu;
|
|
13
15
|
const CALCULATE_HASH_TOOL = {
|
|
14
16
|
title: 'Calculate Hash',
|
|
@@ -52,9 +54,19 @@ function updateCompositeHash(hasher, pathLengthBytes, relativePath, fileHash) {
|
|
|
52
54
|
hasher.update(relativePathBytes);
|
|
53
55
|
hasher.update(fileHash);
|
|
54
56
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
function reportHashProgress(onProgress, current, force = false) {
|
|
58
|
+
if (!onProgress || current === 0)
|
|
59
|
+
return;
|
|
60
|
+
if (!force && current % 25 !== 0)
|
|
61
|
+
return;
|
|
62
|
+
onProgress({ current });
|
|
63
|
+
}
|
|
64
|
+
async function hashDirectory(dirPath, options = {}) {
|
|
65
|
+
const { signal, onProgress } = options;
|
|
66
|
+
const gitignoreMatcher = await loadRootGitignore(dirPath, signal);
|
|
67
|
+
// Enumerate all files in directory.
|
|
57
68
|
const entries = [];
|
|
69
|
+
let filesHashed = 0;
|
|
58
70
|
for await (const entry of globEntries({
|
|
59
71
|
cwd: dirPath,
|
|
60
72
|
pattern: '**/*',
|
|
@@ -68,12 +80,19 @@ async function hashDirectory(dirPath, signal) {
|
|
|
68
80
|
suppressErrors: true,
|
|
69
81
|
})) {
|
|
70
82
|
assertNotAborted(signal);
|
|
83
|
+
if (gitignoreMatcher &&
|
|
84
|
+
isIgnoredByGitignore(gitignoreMatcher, dirPath, entry.path)) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
71
87
|
// entry.path is already absolute, no need to join
|
|
72
88
|
const fileHash = await hashFile(entry.path, undefined, signal);
|
|
73
89
|
// Use posix separators so hashes are stable across OS path separators.
|
|
74
90
|
const relativePath = toStableRelativePath(dirPath, entry.path);
|
|
75
91
|
entries.push({ path: relativePath, hash: fileHash });
|
|
92
|
+
filesHashed++;
|
|
93
|
+
reportHashProgress(onProgress, filesHashed);
|
|
76
94
|
}
|
|
95
|
+
reportHashProgress(onProgress, filesHashed, true);
|
|
77
96
|
assertNotAborted(signal);
|
|
78
97
|
// Sort by path with byte-wise semantics for deterministic ordering.
|
|
79
98
|
entries.sort(comparePaths);
|
|
@@ -89,13 +108,16 @@ async function hashDirectory(dirPath, signal) {
|
|
|
89
108
|
fileCount: entries.length,
|
|
90
109
|
};
|
|
91
110
|
}
|
|
92
|
-
async function handleCalculateHash(args, signal) {
|
|
111
|
+
async function handleCalculateHash(args, signal, onProgress) {
|
|
93
112
|
const validPath = await validateExistingPath(args.path, signal);
|
|
94
113
|
// Check if path is a directory or file
|
|
95
114
|
const stats = await withAbort(fs.stat(validPath), signal);
|
|
96
115
|
if (stats.isDirectory()) {
|
|
97
116
|
// Hash directory: composite hash of all files
|
|
98
|
-
const { hash, fileCount } = await hashDirectory(validPath,
|
|
117
|
+
const { hash, fileCount } = await hashDirectory(validPath, {
|
|
118
|
+
...(signal ? { signal } : {}),
|
|
119
|
+
...(onProgress ? { onProgress } : {}),
|
|
120
|
+
});
|
|
99
121
|
return buildToolResponse(`${hash} (${fileCount} files)`, {
|
|
100
122
|
ok: true,
|
|
101
123
|
path: validPath,
|
|
@@ -107,6 +129,7 @@ async function handleCalculateHash(args, signal) {
|
|
|
107
129
|
else {
|
|
108
130
|
// Hash single file
|
|
109
131
|
const hash = await hashFile(validPath, 'hex', signal);
|
|
132
|
+
reportHashProgress(onProgress, 1, true);
|
|
110
133
|
return buildToolResponse(hash, {
|
|
111
134
|
ok: true,
|
|
112
135
|
path: validPath,
|
|
@@ -117,19 +140,42 @@ async function handleCalculateHash(args, signal) {
|
|
|
117
140
|
}
|
|
118
141
|
export function registerCalculateHashTool(server, options = {}) {
|
|
119
142
|
const handler = (args, extra) => withToolDiagnostics('calculate_hash', () => withToolErrorHandling(async () => {
|
|
143
|
+
notifyProgress(extra, {
|
|
144
|
+
current: 0,
|
|
145
|
+
message: `🕮 calculate_hash: ${path.basename(args.path)}`,
|
|
146
|
+
});
|
|
120
147
|
const { signal, cleanup } = createTimedAbortSignal(extra.signal);
|
|
121
148
|
try {
|
|
122
|
-
|
|
149
|
+
const result = await handleCalculateHash(args, signal, createProgressReporter(extra));
|
|
150
|
+
const sc = result.structuredContent;
|
|
151
|
+
const totalFiles = sc.ok ? (sc.fileCount ?? 1) : 1;
|
|
152
|
+
const finalCurrent = totalFiles + 1;
|
|
153
|
+
const suffix = sc.ok
|
|
154
|
+
? `${(sc.hash ?? '').slice(0, 8)}...`
|
|
155
|
+
: 'failed';
|
|
156
|
+
notifyProgress(extra, {
|
|
157
|
+
current: finalCurrent,
|
|
158
|
+
message: `🕮 calculate_hash: ${path.basename(args.path)} ➟ ${suffix}`,
|
|
159
|
+
});
|
|
160
|
+
return result;
|
|
123
161
|
}
|
|
124
162
|
finally {
|
|
125
163
|
cleanup();
|
|
126
164
|
}
|
|
127
165
|
}, (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path)), { path: args.path });
|
|
128
|
-
|
|
166
|
+
const wrappedHandler = wrapToolHandler(handler, {
|
|
129
167
|
guard: options.isInitialized,
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
168
|
+
});
|
|
169
|
+
const taskOptions = options.isInitialized
|
|
170
|
+
? { guard: options.isInitialized }
|
|
171
|
+
: undefined;
|
|
172
|
+
const tasks = getExperimentalTaskRegistration(server);
|
|
173
|
+
if (tasks?.registerToolTask) {
|
|
174
|
+
tasks.registerToolTask('calculate_hash', withDefaultIcons({
|
|
175
|
+
...CALCULATE_HASH_TOOL,
|
|
176
|
+
execution: { taskSupport: 'optional' },
|
|
177
|
+
}, options.iconInfo), createToolTaskHandler(wrappedHandler, taskOptions));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
server.registerTool('calculate_hash', withDefaultIcons({ ...CALCULATE_HASH_TOOL }, options.iconInfo), wrappedHandler);
|
|
135
181
|
}
|
package/dist/tools/edit-file.js
CHANGED
|
@@ -21,25 +21,45 @@ function applyEdits(content, edits) {
|
|
|
21
21
|
let newContent = content;
|
|
22
22
|
let appliedEdits = 0;
|
|
23
23
|
const unmatchedEdits = [];
|
|
24
|
+
let minLine;
|
|
25
|
+
let maxLine;
|
|
24
26
|
for (const edit of edits) {
|
|
25
27
|
if (!newContent.includes(edit.oldText)) {
|
|
26
28
|
unmatchedEdits.push(edit.oldText);
|
|
27
29
|
continue;
|
|
28
30
|
}
|
|
31
|
+
const index = newContent.indexOf(edit.oldText);
|
|
32
|
+
const linesBefore = newContent.slice(0, index).split('\n').length;
|
|
33
|
+
const newTextLines = edit.newText.split('\n').length;
|
|
34
|
+
const startLine = linesBefore;
|
|
35
|
+
const endLine = linesBefore + newTextLines - 1;
|
|
36
|
+
if (minLine === undefined || startLine < minLine)
|
|
37
|
+
minLine = startLine;
|
|
38
|
+
if (maxLine === undefined || endLine > maxLine)
|
|
39
|
+
maxLine = endLine;
|
|
29
40
|
newContent = newContent.replace(edit.oldText, edit.newText);
|
|
30
41
|
appliedEdits += 1;
|
|
31
42
|
}
|
|
32
|
-
|
|
43
|
+
const result = {
|
|
44
|
+
content: newContent,
|
|
45
|
+
appliedEdits,
|
|
46
|
+
unmatchedEdits,
|
|
47
|
+
};
|
|
48
|
+
if (minLine !== undefined && maxLine !== undefined) {
|
|
49
|
+
result.lineRange = [minLine, maxLine];
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
33
52
|
}
|
|
34
53
|
async function handleEditFile(args, signal) {
|
|
35
54
|
const validPath = await validateExistingPath(args.path, signal);
|
|
36
55
|
const content = await fs.readFile(validPath, { encoding: 'utf-8', signal });
|
|
37
|
-
const { content: newContent, appliedEdits, unmatchedEdits, } = applyEdits(content, args.edits);
|
|
56
|
+
const { content: newContent, appliedEdits, unmatchedEdits, lineRange, } = applyEdits(content, args.edits);
|
|
38
57
|
const structured = {
|
|
39
58
|
ok: true,
|
|
40
59
|
path: validPath,
|
|
41
60
|
appliedEdits,
|
|
42
61
|
...(unmatchedEdits.length > 0 ? { unmatchedEdits } : {}),
|
|
62
|
+
...(lineRange ? { lineRange } : {}),
|
|
43
63
|
};
|
|
44
64
|
if (args.dryRun) {
|
|
45
65
|
return buildToolResponse(`Dry run complete. ${appliedEdits} edits would be applied.`, structured);
|
|
@@ -68,5 +88,17 @@ export function registerEditFileTool(server, options = {}) {
|
|
|
68
88
|
const name = path.basename(args.path);
|
|
69
89
|
return `🛠 edit: ${name} (${args.edits.length} edits)`;
|
|
70
90
|
},
|
|
91
|
+
completionMessage: (args, result) => {
|
|
92
|
+
const name = path.basename(args.path);
|
|
93
|
+
if (result.isError)
|
|
94
|
+
return `🛠 edit: ${name} ➟ Failed`;
|
|
95
|
+
const sc = result.structuredContent;
|
|
96
|
+
if (!sc.ok)
|
|
97
|
+
return `🛠 edit: ${name} ➟ Failed`;
|
|
98
|
+
if (sc.lineRange) {
|
|
99
|
+
return `🛠 edit: ${name} ➟ [${sc.lineRange[0]}-${sc.lineRange[1]}]`;
|
|
100
|
+
}
|
|
101
|
+
return `🛠 edit: ${name} ➟ (${sc.appliedEdits ?? 0} edits)`;
|
|
102
|
+
},
|
|
71
103
|
}));
|
|
72
104
|
}
|
|
@@ -202,7 +202,17 @@ export function registerSearchContentTool(server, options = {}) {
|
|
|
202
202
|
});
|
|
203
203
|
const result = await handleSearchContent(normalizedArgs, extra.signal, options.resourceStore, createProgressReporter(extra));
|
|
204
204
|
const sc = result.structuredContent;
|
|
205
|
-
const
|
|
205
|
+
const count = sc.ok && sc.totalMatches ? sc.totalMatches : 0;
|
|
206
|
+
let suffix;
|
|
207
|
+
if (count === 0) {
|
|
208
|
+
suffix = 'No matches';
|
|
209
|
+
}
|
|
210
|
+
else if (count === 1) {
|
|
211
|
+
suffix = '1 match';
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
suffix = `${count} matches`;
|
|
215
|
+
}
|
|
206
216
|
const finalCurrent = (sc.filesScanned ?? 0) + 1;
|
|
207
217
|
notifyProgress(extra, {
|
|
208
218
|
current: finalCurrent,
|
package/dist/tools/shared.d.ts
CHANGED
|
@@ -54,10 +54,9 @@ export interface ToolRegistrationOptions {
|
|
|
54
54
|
serverIcon?: string;
|
|
55
55
|
iconInfo?: IconInfo;
|
|
56
56
|
}
|
|
57
|
-
export
|
|
57
|
+
export declare function getExperimentalTaskRegistration(server: McpServer): {
|
|
58
58
|
registerToolTask?: (...args: unknown[]) => unknown;
|
|
59
|
-
}
|
|
60
|
-
export declare function getExperimentalTaskRegistration(server: McpServer): ExperimentalTaskRegistration | undefined;
|
|
59
|
+
} | undefined;
|
|
61
60
|
export declare function withToolErrorHandling<T>(run: () => Promise<ToolResponse<T>>, onError: (error: unknown) => ToolResult<T>): Promise<ToolResult<T>>;
|
|
62
61
|
export declare function buildToolErrorResponse(error: unknown, defaultCode: ErrorCode, path?: string): ToolErrorResponse;
|
|
63
62
|
export declare function createProgressReporter(extra: ToolExtra): (progress: {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
import { ErrorCode, McpError } from '../lib/errors.js';
|
|
3
3
|
import { buildToolErrorResponse } from './shared.js';
|
|
4
|
+
const RELATED_TASK_META_KEY = 'io.modelcontextprotocol/related-task';
|
|
5
|
+
const TASK_STATUS_NOTIFICATION_METHOD = 'notifications/tasks/status';
|
|
4
6
|
function isRecord(value) {
|
|
5
7
|
return value !== null && typeof value === 'object';
|
|
6
8
|
}
|
|
@@ -87,6 +89,60 @@ function normalizeCallToolResult(value) {
|
|
|
87
89
|
return parsed.data;
|
|
88
90
|
throw new McpError(ErrorCode.E_INVALID_INPUT, 'Stored task result is not a valid tool result.');
|
|
89
91
|
}
|
|
92
|
+
function withRelatedTaskMeta(result, taskId) {
|
|
93
|
+
if (!isRecord(result)) {
|
|
94
|
+
return {
|
|
95
|
+
_meta: {
|
|
96
|
+
[RELATED_TASK_META_KEY]: { taskId },
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const existingMeta = isRecord(result['_meta']) ? result['_meta'] : {};
|
|
101
|
+
return {
|
|
102
|
+
...result,
|
|
103
|
+
_meta: {
|
|
104
|
+
...existingMeta,
|
|
105
|
+
[RELATED_TASK_META_KEY]: { taskId },
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function getTaskStatusNotificationSender(extra) {
|
|
110
|
+
const candidate = extra.sendNotification;
|
|
111
|
+
return typeof candidate === 'function'
|
|
112
|
+
? candidate
|
|
113
|
+
: undefined;
|
|
114
|
+
}
|
|
115
|
+
function buildTaskStatusNotificationParams(task) {
|
|
116
|
+
return {
|
|
117
|
+
taskId: task.taskId,
|
|
118
|
+
status: task.status,
|
|
119
|
+
ttl: task.ttl,
|
|
120
|
+
createdAt: task.createdAt,
|
|
121
|
+
lastUpdatedAt: task.lastUpdatedAt,
|
|
122
|
+
...(task.pollInterval !== undefined
|
|
123
|
+
? { pollInterval: task.pollInterval }
|
|
124
|
+
: {}),
|
|
125
|
+
...(task.statusMessage !== undefined
|
|
126
|
+
? { statusMessage: task.statusMessage }
|
|
127
|
+
: {}),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
async function notifyTaskStatusIfPossible(extra, taskStore, taskId) {
|
|
131
|
+
const sendNotification = getTaskStatusNotificationSender(extra);
|
|
132
|
+
if (!sendNotification)
|
|
133
|
+
return;
|
|
134
|
+
try {
|
|
135
|
+
const task = await taskStore.getTask(taskId);
|
|
136
|
+
const normalized = normalizeGetTaskResult(task);
|
|
137
|
+
await sendNotification({
|
|
138
|
+
method: TASK_STATUS_NOTIFICATION_METHOD,
|
|
139
|
+
params: buildTaskStatusNotificationParams(normalized),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Never fail task execution because status notifications are optional.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
90
146
|
function getTaskStore(extra) {
|
|
91
147
|
if (!extra.taskStore) {
|
|
92
148
|
throw new McpError(ErrorCode.E_INVALID_INPUT, 'Task store not configured for task-capable tool.');
|
|
@@ -130,11 +186,12 @@ async function getCurrentTaskStatus(taskStore, taskId) {
|
|
|
130
186
|
}
|
|
131
187
|
}
|
|
132
188
|
async function tryStoreTaskResult(taskStore, taskId, status, result) {
|
|
189
|
+
const resultWithTaskMeta = withRelatedTaskMeta(result, taskId);
|
|
133
190
|
const beforeStatus = await getCurrentTaskStatus(taskStore, taskId);
|
|
134
191
|
if (isTerminalTaskStatus(beforeStatus))
|
|
135
192
|
return;
|
|
136
193
|
try {
|
|
137
|
-
await taskStore.storeTaskResult(taskId, status,
|
|
194
|
+
await taskStore.storeTaskResult(taskId, status, resultWithTaskMeta);
|
|
138
195
|
}
|
|
139
196
|
catch (error) {
|
|
140
197
|
const afterStatus = await getCurrentTaskStatus(taskStore, taskId);
|
|
@@ -160,16 +217,19 @@ export function createToolTaskHandler(run, options) {
|
|
|
160
217
|
taskStore,
|
|
161
218
|
taskId: task.taskId,
|
|
162
219
|
};
|
|
220
|
+
void notifyTaskStatusIfPossible(taskExtra, taskStore, task.taskId);
|
|
163
221
|
void (async () => {
|
|
164
222
|
try {
|
|
165
223
|
const result = await run(args, taskExtra);
|
|
166
224
|
const status = isErrorResult(result) ? 'failed' : 'completed';
|
|
167
225
|
await tryStoreTaskResult(taskStore, task.taskId, status, result);
|
|
226
|
+
await notifyTaskStatusIfPossible(taskExtra, taskStore, task.taskId);
|
|
168
227
|
}
|
|
169
228
|
catch (error) {
|
|
170
229
|
const fallback = buildToolErrorResponse(error, ErrorCode.E_UNKNOWN);
|
|
171
230
|
try {
|
|
172
231
|
await tryStoreTaskResult(taskStore, task.taskId, 'failed', fallback);
|
|
232
|
+
await notifyTaskStatusIfPossible(taskExtra, taskStore, task.taskId);
|
|
173
233
|
}
|
|
174
234
|
catch {
|
|
175
235
|
// Swallow to avoid unhandled rejections from background task writes.
|
|
@@ -190,7 +250,7 @@ export function createToolTaskHandler(run, options) {
|
|
|
190
250
|
const taskStore = getTaskStore(extra);
|
|
191
251
|
const taskId = getTaskId(extra);
|
|
192
252
|
const result = await taskStore.getTaskResult(taskId);
|
|
193
|
-
return normalizeCallToolResult(result);
|
|
253
|
+
return normalizeCallToolResult(withRelatedTaskMeta(result, taskId));
|
|
194
254
|
});
|
|
195
255
|
return {
|
|
196
256
|
createTask,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@j0hanz/filesystem-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"mcpName": "io.github.j0hanz/filesystem-mcp",
|
|
5
5
|
"description": "MCP Server that enables LLMs to interact with the local filesystem.",
|
|
6
6
|
"type": "module",
|
|
@@ -73,7 +73,6 @@
|
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@eslint/js": "^9.39.2",
|
|
75
75
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
|
76
|
-
"@types/diff": "^7.0.2",
|
|
77
76
|
"@types/node": "^24",
|
|
78
77
|
"eslint": "^9.39.2",
|
|
79
78
|
"eslint-config-prettier": "^10.1.8",
|