@j0hanz/filesystem-mcp 1.8.0 → 1.9.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 +635 -377
- package/dist/cli.js +2 -2
- package/dist/completions.js +2 -3
- package/dist/index.js +2 -2
- package/dist/lib/file-operations/{common.d.ts → core.d.ts} +6 -0
- package/dist/lib/file-operations/{common.js → core.js} +45 -0
- package/dist/lib/file-operations/metadata.d.ts +73 -0
- package/dist/lib/file-operations/metadata.js +889 -0
- package/dist/lib/file-operations/{search-content.d.ts → search.d.ts} +40 -7
- package/dist/lib/file-operations/{search-content.js → search.js} +418 -20
- package/dist/lib/file-operations/{glob-engine.d.ts → traversal.d.ts} +18 -1
- package/dist/lib/file-operations/{glob-engine.js → traversal.js} +25 -2
- package/dist/lib/fs-helpers.js +1 -2
- package/dist/lib/observability.js +1 -1
- package/dist/lib/{path-validation.d.ts → paths.d.ts} +11 -0
- package/dist/lib/{path-validation.js → paths.js} +145 -12
- package/dist/lib/utils.d.ts +15 -0
- package/dist/lib/utils.js +33 -0
- package/dist/resources/generated-instructions.js +6 -6
- package/dist/resources/tool-catalog.js +9 -9
- package/dist/resources/tool-info.js +3 -3
- package/dist/resources/workflows.js +10 -23
- package/dist/schemas.d.ts +11 -11
- package/dist/schemas.js +18 -31
- package/dist/server/bootstrap.d.ts +19 -1
- package/dist/server/bootstrap.js +130 -44
- package/dist/server/roots-manager.d.ts +5 -2
- package/dist/server/roots-manager.js +17 -5
- package/dist/tools/apply-patch.js +4 -3
- package/dist/tools/calculate-hash.js +4 -4
- package/dist/tools/create-directory.js +1 -1
- package/dist/tools/delete-file.js +3 -3
- package/dist/tools/diff-files.js +2 -2
- package/dist/tools/edit-file.js +14 -13
- package/dist/tools/list-directory.js +5 -7
- package/dist/tools/move-file.js +2 -1
- package/dist/tools/read-multiple.js +3 -4
- package/dist/tools/read.js +3 -3
- package/dist/tools/replace-in-files.js +8 -10
- package/dist/tools/roots.js +4 -6
- package/dist/tools/search-content.js +7 -9
- package/dist/tools/search-files.js +4 -6
- package/dist/tools/shared.d.ts +2 -1
- package/dist/tools/shared.js +2 -1
- package/dist/tools/stat-many.js +4 -3
- package/dist/tools/stat.js +4 -3
- package/dist/tools/task-support.js +1 -1
- package/dist/tools/tree.js +3 -4
- package/dist/tools/write-file.js +1 -1
- package/package.json +4 -4
- package/dist/lib/file-operations/file-info.d.ts +0 -10
- package/dist/lib/file-operations/file-info.js +0 -143
- package/dist/lib/file-operations/gitignore.d.ts +0 -6
- package/dist/lib/file-operations/gitignore.js +0 -45
- package/dist/lib/file-operations/glob-helpers.d.ts +0 -18
- package/dist/lib/file-operations/glob-helpers.js +0 -23
- package/dist/lib/file-operations/list-directory.d.ts +0 -14
- package/dist/lib/file-operations/list-directory.js +0 -252
- package/dist/lib/file-operations/read-multiple-files.d.ts +0 -25
- package/dist/lib/file-operations/read-multiple-files.js +0 -252
- package/dist/lib/file-operations/search-files.d.ts +0 -27
- package/dist/lib/file-operations/search-files.js +0 -216
- package/dist/lib/file-operations/search-matcher.d.ts +0 -10
- package/dist/lib/file-operations/search-matcher.js +0 -72
- package/dist/lib/file-operations/search-worker.d.ts +0 -2
- package/dist/lib/file-operations/search-worker.js +0 -131
- package/dist/lib/file-operations/tree.d.ts +0 -28
- package/dist/lib/file-operations/tree.js +0 -265
- package/dist/lib/option-utils.d.ts +0 -3
- package/dist/lib/option-utils.js +0 -15
- package/dist/lib/path-format.d.ts +0 -1
- package/dist/lib/path-format.js +0 -7
- package/dist/lib/path-policy.d.ts +0 -2
- package/dist/lib/path-policy.js +0 -100
- package/dist/lib/progress-reporting.d.ts +0 -11
- package/dist/lib/progress-reporting.js +0 -13
- package/dist/lib/type-guards.d.ts +0 -1
- package/dist/lib/type-guards.js +0 -3
- package/dist/server/capabilities.d.ts +0 -10
- package/dist/server/capabilities.js +0 -48
- package/dist/server/logging.d.ts +0 -7
- package/dist/server/logging.js +0 -41
- package/dist/server/types.d.ts +0 -4
- package/dist/server/types.js +0 -1
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import * as http from 'node:http';
|
|
2
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
-
import type {
|
|
3
|
+
import type { LoggingLevel } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
export interface ServerOptions {
|
|
5
|
+
allowCwd?: boolean;
|
|
6
|
+
cliAllowedDirs?: string[];
|
|
7
|
+
}
|
|
8
|
+
interface CapabilityOptions {
|
|
9
|
+
enablePromptListChanged?: boolean;
|
|
10
|
+
enableTaskToolRequests?: boolean;
|
|
11
|
+
}
|
|
12
|
+
type ServerCapabilities = NonNullable<ConstructorParameters<typeof McpServer>[1]>['capabilities'];
|
|
13
|
+
type NonOptionalServerCapabilities = NonNullable<ServerCapabilities>;
|
|
14
|
+
export declare function buildServerCapabilities(options?: CapabilityOptions): NonOptionalServerCapabilities;
|
|
15
|
+
export declare function supportsTaskToolRequests(): boolean;
|
|
16
|
+
export interface LoggingState {
|
|
17
|
+
minimumLevel: LoggingLevel;
|
|
18
|
+
}
|
|
19
|
+
export declare function createLoggingState(minimumLevel?: LoggingLevel): LoggingState;
|
|
20
|
+
export declare function logToMcp(server: McpServer | undefined, level: LoggingLevel, data: string, minLevel?: LoggingLevel): void;
|
|
4
21
|
export declare function createServer(options?: ServerOptions): Promise<McpServer>;
|
|
5
22
|
export declare function startServer(server: McpServer): Promise<void>;
|
|
6
23
|
export declare function startHttpServer(port: number, options: ServerOptions): Promise<http.Server>;
|
|
24
|
+
export {};
|
package/dist/server/bootstrap.js
CHANGED
|
@@ -6,19 +6,105 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
6
6
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
7
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
8
8
|
import { isInitializeRequest, SetLevelRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
-
import {
|
|
10
|
-
import { DEFAULT_LOG_LEVEL, parseEnvInt, REQUIRED_MCP_PROTOCOL_VERSION, } from '../lib/constants.js';
|
|
9
|
+
import { DEFAULT_LOG_LEVEL, parseEnvInt } from '../lib/constants.js';
|
|
11
10
|
import { formatUnknownErrorMessage } from '../lib/errors.js';
|
|
11
|
+
import { withAllowedDirectoriesState } from '../lib/paths.js';
|
|
12
12
|
import { createInMemoryResourceStore } from '../lib/resource-store.js';
|
|
13
|
+
import { isRecord } from '../lib/utils.js';
|
|
14
|
+
import { registerCompletions } from '../completions.js';
|
|
13
15
|
import { pkgInfo } from '../pkg-info.js';
|
|
14
16
|
import { registerGetHelpPrompt } from '../prompts.js';
|
|
15
17
|
import { registerInstructionResource, registerMetricsResource, registerResultResources, registerToolCatalogResource, registerToolInfoResource, registerWorkflowGuideResource, } from '../resources.js';
|
|
16
18
|
import { buildServerInstructions } from '../resources/generated-instructions.js';
|
|
17
19
|
import { registerAllTools } from '../tools.js';
|
|
18
20
|
import { withDefaultIcons } from '../tools/shared.js';
|
|
19
|
-
import { buildServerCapabilities, supportsTaskToolRequests, } from './capabilities.js';
|
|
20
|
-
import { createLoggingState } from './logging.js';
|
|
21
21
|
import { RootsManager } from './roots-manager.js';
|
|
22
|
+
let cachedTaskToolSupport;
|
|
23
|
+
function detectTaskToolSupport() {
|
|
24
|
+
if (cachedTaskToolSupport !== undefined) {
|
|
25
|
+
return cachedTaskToolSupport;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
// Instantiate a minimal, unconnected probe server to duck-type check for
|
|
29
|
+
// task tool support. The probe has no transport or active connections, so
|
|
30
|
+
// close() only releases in-memory state; fire-and-forget is safe here.
|
|
31
|
+
const probe = new McpServer({
|
|
32
|
+
name: 'filesystem-mcp-capability-probe',
|
|
33
|
+
version: '0.0.0',
|
|
34
|
+
}, { capabilities: { tools: {} } });
|
|
35
|
+
cachedTaskToolSupport =
|
|
36
|
+
typeof probe.experimental.tasks.registerToolTask === 'function';
|
|
37
|
+
probe.close().catch(() => { });
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
cachedTaskToolSupport = false;
|
|
41
|
+
}
|
|
42
|
+
return cachedTaskToolSupport;
|
|
43
|
+
}
|
|
44
|
+
export function buildServerCapabilities(options = {}) {
|
|
45
|
+
const capabilities = {
|
|
46
|
+
logging: {},
|
|
47
|
+
resources: {},
|
|
48
|
+
tools: {},
|
|
49
|
+
prompts: options.enablePromptListChanged ? { listChanged: true } : {},
|
|
50
|
+
completions: {},
|
|
51
|
+
};
|
|
52
|
+
if (options.enableTaskToolRequests) {
|
|
53
|
+
// NOTE: enabling task tool requests requires the caller to configure
|
|
54
|
+
// an InMemoryTaskStore and InMemoryTaskMessageQueue on the McpServer.
|
|
55
|
+
// InMemoryTaskStore accumulates completed task records with no TTL eviction —
|
|
56
|
+
// suitable for short-lived stdio sessions. Long-running HTTP servers should
|
|
57
|
+
// replace it with a TTL-evicting store to avoid unbounded memory growth.
|
|
58
|
+
capabilities.tasks = {
|
|
59
|
+
list: {},
|
|
60
|
+
cancel: {},
|
|
61
|
+
requests: { tools: { call: {} } },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return capabilities;
|
|
65
|
+
}
|
|
66
|
+
export function supportsTaskToolRequests() {
|
|
67
|
+
return detectTaskToolSupport();
|
|
68
|
+
}
|
|
69
|
+
const MCP_LOGGER_NAME = 'filesystem-mcp';
|
|
70
|
+
const LOG_LEVEL_ORDER = {
|
|
71
|
+
debug: 0,
|
|
72
|
+
info: 1,
|
|
73
|
+
notice: 2,
|
|
74
|
+
warning: 3,
|
|
75
|
+
error: 4,
|
|
76
|
+
critical: 5,
|
|
77
|
+
alert: 6,
|
|
78
|
+
emergency: 7,
|
|
79
|
+
};
|
|
80
|
+
export function createLoggingState(minimumLevel = 'debug') {
|
|
81
|
+
return { minimumLevel };
|
|
82
|
+
}
|
|
83
|
+
function canSendMcpLogs(server) {
|
|
84
|
+
const capabilities = server.server.getClientCapabilities();
|
|
85
|
+
if (!isRecord(capabilities))
|
|
86
|
+
return false;
|
|
87
|
+
if (!('logging' in capabilities))
|
|
88
|
+
return false;
|
|
89
|
+
return !!capabilities['logging'];
|
|
90
|
+
}
|
|
91
|
+
export function logToMcp(server, level, data, minLevel = 'debug') {
|
|
92
|
+
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[minLevel]) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (!server || !canSendMcpLogs(server)) {
|
|
96
|
+
console.error(data);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const params = {
|
|
100
|
+
level,
|
|
101
|
+
logger: MCP_LOGGER_NAME,
|
|
102
|
+
data,
|
|
103
|
+
};
|
|
104
|
+
void server.sendLoggingMessage(params).catch((error) => {
|
|
105
|
+
console.error(`Failed to send MCP log: ${level} | ${data}`, formatUnknownErrorMessage(error));
|
|
106
|
+
});
|
|
107
|
+
}
|
|
22
108
|
const { version: SERVER_VERSION, description: SERVER_DESCRIPTION, homepage: SERVER_HOMEPAGE, } = pkgInfo;
|
|
23
109
|
const rootsManagers = new WeakMap();
|
|
24
110
|
function getRootsManager(server) {
|
|
@@ -164,7 +250,7 @@ async function createHttpSession(options, sessions) {
|
|
|
164
250
|
const transport = new StreamableHTTPServerTransport({
|
|
165
251
|
sessionIdGenerator: () => randomUUID(),
|
|
166
252
|
onsessioninitialized: (sessionId) => {
|
|
167
|
-
sessions.set(sessionId, { server: mcpServer, transport });
|
|
253
|
+
sessions.set(sessionId, { server: mcpServer, rootsManager, transport });
|
|
168
254
|
rootsManager.logMissingDirectoriesIfNeeded(mcpServer);
|
|
169
255
|
},
|
|
170
256
|
});
|
|
@@ -179,7 +265,7 @@ async function createHttpSession(options, sessions) {
|
|
|
179
265
|
});
|
|
180
266
|
};
|
|
181
267
|
await mcpServer.connect(transport);
|
|
182
|
-
return { server: mcpServer, transport };
|
|
268
|
+
return { server: mcpServer, rootsManager, transport };
|
|
183
269
|
}
|
|
184
270
|
function sendJsonRpcError(res, status, code, message) {
|
|
185
271
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
@@ -195,32 +281,33 @@ function isAllowedOrigin(origin) {
|
|
|
195
281
|
return true; // Non-browser clients omit Origin.
|
|
196
282
|
return LOCALHOST_ORIGIN_RE.test(origin);
|
|
197
283
|
}
|
|
198
|
-
function getProtocolVersionHeader(req) {
|
|
199
|
-
const rawProtocolVersion = req.headers['mcp-protocol-version'];
|
|
200
|
-
if (typeof rawProtocolVersion === 'string') {
|
|
201
|
-
return rawProtocolVersion;
|
|
202
|
-
}
|
|
203
|
-
if (Array.isArray(rawProtocolVersion)) {
|
|
204
|
-
return rawProtocolVersion.find((value) => value === REQUIRED_MCP_PROTOCOL_VERSION);
|
|
205
|
-
}
|
|
206
|
-
return undefined;
|
|
207
|
-
}
|
|
208
|
-
function ensureProtocolVersionHeader(req, res) {
|
|
209
|
-
const protocolVersion = getProtocolVersionHeader(req);
|
|
210
|
-
if (protocolVersion === REQUIRED_MCP_PROTOCOL_VERSION) {
|
|
211
|
-
return true;
|
|
212
|
-
}
|
|
213
|
-
sendJsonRpcError(res, 400, -32000, 'Bad Request: MCP-Protocol-Version header missing or unsupported');
|
|
214
|
-
return false;
|
|
215
|
-
}
|
|
216
284
|
function discardRequestBody(req) {
|
|
217
285
|
req.on('error', () => {
|
|
218
286
|
// Best effort drain to avoid corrupting keep-alive pipelines.
|
|
219
287
|
});
|
|
220
288
|
req.resume();
|
|
221
289
|
}
|
|
290
|
+
async function handleSessionTransportRequest(session, req, res, body) {
|
|
291
|
+
await withAllowedDirectoriesState(session.rootsManager.getAllowedDirectoriesState(), () => session.transport.handleRequest(req, res, body));
|
|
292
|
+
}
|
|
293
|
+
function isLoopbackHttpHost(host) {
|
|
294
|
+
const normalized = host.trim().toLowerCase();
|
|
295
|
+
return (normalized === '127.0.0.1' ||
|
|
296
|
+
normalized === 'localhost' ||
|
|
297
|
+
normalized === '::1' ||
|
|
298
|
+
normalized === '[::1]');
|
|
299
|
+
}
|
|
300
|
+
function assertHttpBindingSecurity(host) {
|
|
301
|
+
if (isLoopbackHttpHost(host))
|
|
302
|
+
return;
|
|
303
|
+
if (process.env['FILESYSTEM_MCP_API_KEY'])
|
|
304
|
+
return;
|
|
305
|
+
throw new Error(`Refusing to bind HTTP server to non-loopback host '${host}' without FILESYSTEM_MCP_API_KEY.`);
|
|
306
|
+
}
|
|
222
307
|
export async function startHttpServer(port, options) {
|
|
223
308
|
const sessions = new Map();
|
|
309
|
+
const httpHost = process.env['FILESYSTEM_MCP_HTTP_HOST'] ?? '127.0.0.1';
|
|
310
|
+
assertHttpBindingSecurity(httpHost);
|
|
224
311
|
async function handleMcpRequest(req, res) {
|
|
225
312
|
const { method } = req;
|
|
226
313
|
const MAX_SESSION_ID_LENGTH = 256;
|
|
@@ -242,9 +329,11 @@ export async function startHttpServer(port, options) {
|
|
|
242
329
|
if (typeof authHeader === 'string' &&
|
|
243
330
|
authHeader.startsWith(bearerPrefix)) {
|
|
244
331
|
const userKey = authHeader.slice(bearerPrefix.length);
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
332
|
+
if (userKey.length <= 4096) {
|
|
333
|
+
const expectedHash = createHash('sha256').update(apiKey).digest();
|
|
334
|
+
const actualHash = createHash('sha256').update(userKey).digest();
|
|
335
|
+
authorized = timingSafeEqual(expectedHash, actualHash);
|
|
336
|
+
}
|
|
248
337
|
}
|
|
249
338
|
if (!authorized) {
|
|
250
339
|
res.writeHead(401, {
|
|
@@ -267,14 +356,10 @@ export async function startHttpServer(port, options) {
|
|
|
267
356
|
discardRequestBody(req);
|
|
268
357
|
return;
|
|
269
358
|
}
|
|
270
|
-
if (!ensureProtocolVersionHeader(req, res)) {
|
|
271
|
-
discardRequestBody(req);
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
359
|
const body = await readRequestBody(req);
|
|
275
360
|
const session = sessions.get(sessionId);
|
|
276
361
|
if (session) {
|
|
277
|
-
await session
|
|
362
|
+
await handleSessionTransportRequest(session, req, res, body);
|
|
278
363
|
}
|
|
279
364
|
else {
|
|
280
365
|
sendJsonRpcError(res, 404, -32000, 'Session not found');
|
|
@@ -283,13 +368,13 @@ export async function startHttpServer(port, options) {
|
|
|
283
368
|
}
|
|
284
369
|
const body = await readRequestBody(req);
|
|
285
370
|
if (isInitializeRequest(body)) {
|
|
286
|
-
const maxSessions =
|
|
371
|
+
const maxSessions = parseEnvInt('FILESYSTEM_MCP_MAX_HTTP_SESSIONS', 100, 1, 10_000);
|
|
287
372
|
if (sessions.size >= maxSessions) {
|
|
288
373
|
sendJsonRpcError(res, 503, -32000, 'Too many sessions');
|
|
289
374
|
return;
|
|
290
375
|
}
|
|
291
|
-
const
|
|
292
|
-
await
|
|
376
|
+
const session = await createHttpSession(options, sessions);
|
|
377
|
+
await handleSessionTransportRequest(session, req, res, body);
|
|
293
378
|
return;
|
|
294
379
|
}
|
|
295
380
|
sendJsonRpcError(res, 400, -32000, 'Bad Request: No valid session ID provided');
|
|
@@ -304,20 +389,24 @@ export async function startHttpServer(port, options) {
|
|
|
304
389
|
sendJsonRpcError(res, 404, -32000, 'Session not found');
|
|
305
390
|
return;
|
|
306
391
|
}
|
|
307
|
-
if (!ensureProtocolVersionHeader(req, res)) {
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
392
|
const session = sessions.get(sessionId);
|
|
311
393
|
if (session) {
|
|
312
|
-
await session
|
|
394
|
+
await handleSessionTransportRequest(session, req, res);
|
|
313
395
|
}
|
|
314
396
|
else {
|
|
315
397
|
sendJsonRpcError(res, 404, -32000, 'Session not found');
|
|
316
398
|
}
|
|
317
399
|
}
|
|
318
400
|
else {
|
|
319
|
-
res.writeHead(405, {
|
|
320
|
-
|
|
401
|
+
res.writeHead(405, {
|
|
402
|
+
Allow: 'GET, POST, DELETE',
|
|
403
|
+
'Content-Type': 'application/json',
|
|
404
|
+
});
|
|
405
|
+
res.end(JSON.stringify({
|
|
406
|
+
jsonrpc: '2.0',
|
|
407
|
+
error: { code: -32000, message: 'Method Not Allowed' },
|
|
408
|
+
id: null,
|
|
409
|
+
}));
|
|
321
410
|
}
|
|
322
411
|
}
|
|
323
412
|
catch (error) {
|
|
@@ -345,9 +434,6 @@ export async function startHttpServer(port, options) {
|
|
|
345
434
|
res.end('Not Found');
|
|
346
435
|
}
|
|
347
436
|
});
|
|
348
|
-
// Default to localhost-only binding to prevent DNS-rebinding and unintended
|
|
349
|
-
// external exposure. Override with FILESYSTEM_MCP_HTTP_HOST for remote setups.
|
|
350
|
-
const httpHost = process.env['FILESYSTEM_MCP_HTTP_HOST'] ?? '127.0.0.1';
|
|
351
437
|
return new Promise((resolve, reject) => {
|
|
352
438
|
httpServer.once('error', reject);
|
|
353
439
|
httpServer.listen(port, httpHost, () => {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
-
import { type
|
|
3
|
-
import type
|
|
2
|
+
import { type AllowedDirectoriesState } from '../lib/paths.js';
|
|
3
|
+
import { type LoggingState } from './bootstrap.js';
|
|
4
|
+
import type { ServerOptions } from './bootstrap.js';
|
|
4
5
|
export declare class RootsManager {
|
|
5
6
|
private rootsUpdateTimeout;
|
|
6
7
|
private rootDirectories;
|
|
8
|
+
private allowedDirectoriesState;
|
|
7
9
|
private clientInitialized;
|
|
8
10
|
private updatingRoots;
|
|
9
11
|
private pendingRootsUpdate;
|
|
@@ -12,6 +14,7 @@ export declare class RootsManager {
|
|
|
12
14
|
constructor(options: ServerOptions, loggingState: LoggingState);
|
|
13
15
|
isInitialized(): boolean;
|
|
14
16
|
destroy(): void;
|
|
17
|
+
getAllowedDirectoriesState(): AllowedDirectoriesState;
|
|
15
18
|
logMissingDirectoriesIfNeeded(server: McpServer): void;
|
|
16
19
|
registerHandlers(server: McpServer): void;
|
|
17
20
|
recomputeAllowedDirectories(): Promise<void>;
|
|
@@ -3,9 +3,9 @@ import { InitializedNotificationSchema, RootsListChangedNotificationSchema, } fr
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { formatUnknownErrorMessage } from '../lib/errors.js';
|
|
5
5
|
import { assertNotAborted, createTimedAbortSignal, withAbort, } from '../lib/fs-helpers.js';
|
|
6
|
-
import {
|
|
7
|
-
import { isRecord } from '../lib/
|
|
8
|
-
import { logToMcp } from './
|
|
6
|
+
import { getValidRootDirectories, isPathWithinDirectories, normalizePath, resolveAllowedDirectoriesState, setAllowedDirectoriesStateResolved, } from '../lib/paths.js';
|
|
7
|
+
import { isRecord } from '../lib/utils.js';
|
|
8
|
+
import { logToMcp } from './bootstrap.js';
|
|
9
9
|
const ROOTS_TIMEOUT_MS = 5000;
|
|
10
10
|
const ROOTS_DEBOUNCE_MS = 100;
|
|
11
11
|
function normalizeCLIDirectories(dirs) {
|
|
@@ -83,6 +83,10 @@ async function filterRootsWithinBaseline(roots, baseline, signal) {
|
|
|
83
83
|
export class RootsManager {
|
|
84
84
|
rootsUpdateTimeout;
|
|
85
85
|
rootDirectories = [];
|
|
86
|
+
allowedDirectoriesState = {
|
|
87
|
+
primary: [],
|
|
88
|
+
expanded: [],
|
|
89
|
+
};
|
|
86
90
|
clientInitialized = false;
|
|
87
91
|
// Set to true when an update is in progress, to prevent concurrent executions. If a change arrives while true, we queue a single retry after completion to ensure the last-known state is applied. This
|
|
88
92
|
updatingRoots = false;
|
|
@@ -103,8 +107,14 @@ export class RootsManager {
|
|
|
103
107
|
this.rootsUpdateTimeout = undefined;
|
|
104
108
|
}
|
|
105
109
|
}
|
|
110
|
+
getAllowedDirectoriesState() {
|
|
111
|
+
return {
|
|
112
|
+
primary: [...this.allowedDirectoriesState.primary],
|
|
113
|
+
expanded: [...this.allowedDirectoriesState.expanded],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
106
116
|
logMissingDirectoriesIfNeeded(server) {
|
|
107
|
-
if (
|
|
117
|
+
if (this.allowedDirectoriesState.expanded.length === 0) {
|
|
108
118
|
this.logMissingDirectories(server);
|
|
109
119
|
}
|
|
110
120
|
}
|
|
@@ -130,7 +140,9 @@ export class RootsManager {
|
|
|
130
140
|
? await filterRootsWithinBaseline(this.rootDirectories, baseline, signal)
|
|
131
141
|
: this.rootDirectories;
|
|
132
142
|
const combined = [...baseline, ...rootsToInclude];
|
|
133
|
-
await
|
|
143
|
+
const nextState = await resolveAllowedDirectoriesState(combined, signal);
|
|
144
|
+
this.allowedDirectoriesState = nextState;
|
|
145
|
+
setAllowedDirectoriesStateResolved(nextState);
|
|
134
146
|
}
|
|
135
147
|
finally {
|
|
136
148
|
cleanup();
|
|
@@ -4,7 +4,7 @@ import { applyPatch } from 'diff';
|
|
|
4
4
|
import { MAX_TEXT_FILE_SIZE } from '../lib/constants.js';
|
|
5
5
|
import { ErrorCode, McpError } from '../lib/errors.js';
|
|
6
6
|
import { atomicWriteFile, withAbort } from '../lib/fs-helpers.js';
|
|
7
|
-
import { validateExistingPath } from '../lib/
|
|
7
|
+
import { assertAllowedFileAccess, validateExistingPath } from '../lib/paths.js';
|
|
8
8
|
import { ApplyPatchInputSchema, ApplyPatchOutputSchema } from '../schemas.js';
|
|
9
9
|
import { buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
10
10
|
import { registerToolTaskIfAvailable } from './task-support.js';
|
|
@@ -12,8 +12,8 @@ export const APPLY_PATCH_TOOL = {
|
|
|
12
12
|
name: 'apply_patch',
|
|
13
13
|
title: 'Apply Patch',
|
|
14
14
|
description: 'Apply a unified diff patch to a file. ' +
|
|
15
|
-
'
|
|
16
|
-
'On failure, regenerate
|
|
15
|
+
'Workflow: `diff_files` \u2192 `apply_patch(dryRun:true)` \u2192 `apply_patch`. ' +
|
|
16
|
+
'On failure, regenerate the patch from current file content.',
|
|
17
17
|
inputSchema: ApplyPatchInputSchema,
|
|
18
18
|
outputSchema: ApplyPatchOutputSchema,
|
|
19
19
|
annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
|
|
@@ -36,6 +36,7 @@ function assertPatchHasHunks(patch) {
|
|
|
36
36
|
async function handleApplyPatch(args, signal) {
|
|
37
37
|
const maxFileSize = MAX_TEXT_FILE_SIZE;
|
|
38
38
|
const validPath = await validateExistingPath(args.path, signal);
|
|
39
|
+
assertAllowedFileAccess(args.path, validPath);
|
|
39
40
|
const stats = await withAbort(fs.stat(validPath), signal);
|
|
40
41
|
assertPatchTargetSizeWithinLimit(validPath, stats.size, maxFileSize);
|
|
41
42
|
const content = await fs.readFile(validPath, { encoding: 'utf-8', signal });
|
|
@@ -4,11 +4,11 @@ import { createHash } from 'node:crypto';
|
|
|
4
4
|
import { createReadStream } from 'node:fs';
|
|
5
5
|
import { PARALLEL_CONCURRENCY } from '../lib/constants.js';
|
|
6
6
|
import { ErrorCode } from '../lib/errors.js';
|
|
7
|
-
import { isIgnoredByGitignore, loadRootGitignore, } from '../lib/file-operations/
|
|
8
|
-
import { globEntries } from '../lib/file-operations/
|
|
7
|
+
import { isIgnoredByGitignore, loadRootGitignore, } from '../lib/file-operations/core.js';
|
|
8
|
+
import { globEntries } from '../lib/file-operations/traversal.js';
|
|
9
9
|
import { assertNotAborted, withAbort } from '../lib/fs-helpers.js';
|
|
10
|
-
import { validateExistingPath } from '../lib/
|
|
11
|
-
import { reportPeriodicProgress } from '../lib/
|
|
10
|
+
import { validateExistingPath } from '../lib/paths.js';
|
|
11
|
+
import { reportPeriodicProgress } from '../lib/utils.js';
|
|
12
12
|
import { CalculateHashInputSchema, CalculateHashOutputSchema, } from '../schemas.js';
|
|
13
13
|
import { buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
14
14
|
import { registerToolTaskIfAvailable } from './task-support.js';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import { ErrorCode, McpError } from '../lib/errors.js';
|
|
3
3
|
import { withAbort } from '../lib/fs-helpers.js';
|
|
4
|
-
import { validatePathForWrite } from '../lib/
|
|
4
|
+
import { validatePathForWrite } from '../lib/paths.js';
|
|
5
5
|
import { CreateDirectoryInputSchema, CreateDirectoryOutputSchema, } from '../schemas.js';
|
|
6
6
|
import { buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, IDEMPOTENT_WRITE_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
7
7
|
import { registerToolTaskIfAvailable } from './task-support.js';
|
|
@@ -2,7 +2,7 @@ import * as fs from 'node:fs/promises';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { ErrorCode, isNodeError, McpError } from '../lib/errors.js';
|
|
4
4
|
import { withAbort } from '../lib/fs-helpers.js';
|
|
5
|
-
import { isAllowedDirectoryRoot, validatePathForWrite
|
|
5
|
+
import { isAllowedDirectoryRoot, validatePathForWrite } from '../lib/paths.js';
|
|
6
6
|
import { DeleteFileInputSchema, DeleteFileOutputSchema } from '../schemas.js';
|
|
7
7
|
import { buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
8
8
|
import { registerToolTaskIfAvailable } from './task-support.js';
|
|
@@ -14,8 +14,8 @@ export const DELETE_FILE_TOOL = {
|
|
|
14
14
|
outputSchema: DeleteFileOutputSchema,
|
|
15
15
|
annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
|
|
16
16
|
gotchas: [
|
|
17
|
-
'
|
|
18
|
-
'Non-empty
|
|
17
|
+
'No undo — deletion is permanent.',
|
|
18
|
+
'Non-empty directories require `recursive=true`.',
|
|
19
19
|
],
|
|
20
20
|
};
|
|
21
21
|
async function handleDeleteFile(args, signal) {
|
package/dist/tools/diff-files.js
CHANGED
|
@@ -4,7 +4,7 @@ import { createTwoFilesPatch } from 'diff';
|
|
|
4
4
|
import { MAX_TEXT_FILE_SIZE } from '../lib/constants.js';
|
|
5
5
|
import { ErrorCode, McpError } from '../lib/errors.js';
|
|
6
6
|
import { withAbort } from '../lib/fs-helpers.js';
|
|
7
|
-
import { validateExistingPath } from '../lib/
|
|
7
|
+
import { validateExistingPath } from '../lib/paths.js';
|
|
8
8
|
import { DiffFilesInputSchema, DiffFilesOutputSchema } from '../schemas.js';
|
|
9
9
|
import { buildResourceLink, buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, maybeExternalizeTextContent, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
10
10
|
import { registerToolTaskIfAvailable } from './task-support.js';
|
|
@@ -13,7 +13,7 @@ export const DIFF_FILES_TOOL = {
|
|
|
13
13
|
title: 'Diff Files',
|
|
14
14
|
description: 'Generate a unified diff between two files. ' +
|
|
15
15
|
'Output feeds directly into `apply_patch`. ' +
|
|
16
|
-
'
|
|
16
|
+
'`isIdentical=true` means files match \u2014 no patch needed.',
|
|
17
17
|
inputSchema: DiffFilesInputSchema,
|
|
18
18
|
outputSchema: DiffFilesOutputSchema,
|
|
19
19
|
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
package/dist/tools/edit-file.js
CHANGED
|
@@ -1,28 +1,24 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import RE2 from 're2';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { MAX_TEXT_FILE_SIZE } from '../lib/constants.js';
|
|
5
|
+
import { ErrorCode, McpError } from '../lib/errors.js';
|
|
6
|
+
import { atomicWriteFile, withAbort } from '../lib/fs-helpers.js';
|
|
7
|
+
import { assertAllowedFileAccess, validateExistingPath } from '../lib/paths.js';
|
|
7
8
|
import { EditFileInputSchema, EditFileOutputSchema } from '../schemas.js';
|
|
8
9
|
import { buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
9
10
|
import { registerToolTaskIfAvailable } from './task-support.js';
|
|
10
11
|
export const EDIT_FILE_TOOL = {
|
|
11
12
|
name: 'edit',
|
|
12
13
|
title: 'Edit File',
|
|
13
|
-
description: '
|
|
14
|
-
'
|
|
15
|
-
'`
|
|
16
|
-
'Use `dryRun: true` to validate edits before writing.',
|
|
14
|
+
description: 'Apply sequential literal string replacements to a file (first occurrence per edit). ' +
|
|
15
|
+
'`oldText` must match exactly \u2014 include 3\u20135 lines of context for unique targeting. ' +
|
|
16
|
+
'Use `dryRun:true` to preview.',
|
|
17
17
|
inputSchema: EditFileInputSchema,
|
|
18
18
|
outputSchema: EditFileOutputSchema,
|
|
19
19
|
annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
|
|
20
|
-
nuances: [
|
|
21
|
-
|
|
22
|
-
],
|
|
23
|
-
gotchas: [
|
|
24
|
-
'`oldText` must match exactly; unmatched items are reported in `unmatchedEdits`.',
|
|
25
|
-
],
|
|
20
|
+
nuances: ['Each edit applies to the output of the previous edit.'],
|
|
21
|
+
gotchas: ['Unmatched `oldText` entries listed in `unmatchedEdits`.'],
|
|
26
22
|
};
|
|
27
23
|
function escapeRegExp(string) {
|
|
28
24
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -88,6 +84,11 @@ function applyEdits(content, edits, ignoreWhitespace) {
|
|
|
88
84
|
}
|
|
89
85
|
export async function handleEditFile(args, signal) {
|
|
90
86
|
const validPath = await validateExistingPath(args.path, signal);
|
|
87
|
+
assertAllowedFileAccess(args.path, validPath);
|
|
88
|
+
const stats = await withAbort(fs.stat(validPath), signal);
|
|
89
|
+
if (stats.size > MAX_TEXT_FILE_SIZE) {
|
|
90
|
+
throw new McpError(ErrorCode.E_TOO_LARGE, `File too large for edit: ${args.path} (${stats.size} bytes > ${MAX_TEXT_FILE_SIZE} bytes)`, args.path, { size: stats.size, maxFileSize: MAX_TEXT_FILE_SIZE });
|
|
91
|
+
}
|
|
91
92
|
const content = await fs.readFile(validPath, { encoding: 'utf-8', signal });
|
|
92
93
|
const { content: newContent, appliedEdits, unmatchedEdits, lineRange, } = applyEdits(content, args.edits, args.ignoreWhitespace);
|
|
93
94
|
const structured = {
|
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
import * as path from 'node:path';
|
|
2
|
-
import { formatOperationSummary, joinLines } from '../config.js';
|
|
3
2
|
import { DEFAULT_EXCLUDE_PATTERNS } from '../lib/constants.js';
|
|
4
3
|
import { ErrorCode } from '../lib/errors.js';
|
|
5
|
-
import { listDirectory } from '../lib/file-operations/
|
|
4
|
+
import { listDirectory } from '../lib/file-operations/metadata.js';
|
|
5
|
+
import { formatOperationSummary, joinLines } from '../config.js';
|
|
6
6
|
import { ListDirectoryInputSchema, ListDirectoryOutputSchema, } from '../schemas.js';
|
|
7
7
|
import { buildToolErrorResponse, buildToolResponse, decodeOffsetCursor, encodeOffsetCursor, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
8
8
|
import { registerToolTaskIfAvailable } from './task-support.js';
|
|
9
9
|
export const LIST_DIRECTORY_TOOL = {
|
|
10
10
|
name: 'ls',
|
|
11
11
|
title: 'List Directory',
|
|
12
|
-
description: 'List
|
|
13
|
-
'
|
|
14
|
-
'
|
|
15
|
-
'Use includeIgnored=true to include ignored directories like node_modules. ' +
|
|
16
|
-
'For recursive searches, use find instead.',
|
|
12
|
+
description: 'List immediate directory contents (non-recursive): name, path, type, size, modified date. ' +
|
|
13
|
+
'Omit path for workspace root. `includeIgnored=true` for node_modules etc. ' +
|
|
14
|
+
'For recursive search, use `find`.',
|
|
17
15
|
inputSchema: ListDirectoryInputSchema,
|
|
18
16
|
outputSchema: ListDirectoryOutputSchema,
|
|
19
17
|
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
package/dist/tools/move-file.js
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from 'node:fs/promises';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { ErrorCode, formatUnknownErrorMessage, isNodeError, McpError, } from '../lib/errors.js';
|
|
4
4
|
import { withAbort } from '../lib/fs-helpers.js';
|
|
5
|
-
import { validateExistingPath, validatePathForWrite, } from '../lib/
|
|
5
|
+
import { assertAllowedFileAccess, validateExistingPath, validatePathForWrite, } from '../lib/paths.js';
|
|
6
6
|
import { MoveFileInputSchema, MoveFileOutputSchema } from '../schemas.js';
|
|
7
7
|
import { buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
8
8
|
import { registerToolTaskIfAvailable } from './task-support.js';
|
|
@@ -48,6 +48,7 @@ export async function handleMoveFile(args, signal) {
|
|
|
48
48
|
let validSource;
|
|
49
49
|
try {
|
|
50
50
|
validSource = await validateExistingPath(src, signal);
|
|
51
|
+
assertAllowedFileAccess(src, validSource);
|
|
51
52
|
}
|
|
52
53
|
catch (error) {
|
|
53
54
|
failed.push({
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import * as path from 'node:path';
|
|
2
2
|
import { DEFAULT_READ_MANY_MAX_TOTAL_SIZE, DEFAULT_SEARCH_TIMEOUT_MS, } from '../lib/constants.js';
|
|
3
3
|
import { ErrorCode } from '../lib/errors.js';
|
|
4
|
-
import { readMultipleFiles } from '../lib/file-operations/
|
|
4
|
+
import { readMultipleFiles } from '../lib/file-operations/metadata.js';
|
|
5
5
|
import { ReadMultipleFilesInputSchema, ReadMultipleFilesOutputSchema, } from '../schemas.js';
|
|
6
6
|
import { buildBatchCompletionSuffix, buildBatchPathContext, buildResourceLink, buildToolErrorResponse, buildToolResponse, createBatchProgressCallbacks, executeToolWithDiagnostics, maybeExternalizeTextContent, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
7
7
|
import { registerToolTaskIfAvailable } from './task-support.js';
|
|
8
8
|
export const READ_MULTIPLE_FILES_TOOL = {
|
|
9
9
|
name: 'read_many',
|
|
10
10
|
title: 'Read Multiple Files',
|
|
11
|
-
description: 'Read multiple text files in
|
|
12
|
-
'
|
|
13
|
-
'For single file, use read for simpler output.',
|
|
11
|
+
description: 'Read multiple text files in one request with contents and metadata. ' +
|
|
12
|
+
'For a single file, use `read`.',
|
|
14
13
|
inputSchema: ReadMultipleFilesInputSchema,
|
|
15
14
|
outputSchema: ReadMultipleFilesOutputSchema,
|
|
16
15
|
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
package/dist/tools/read.js
CHANGED
|
@@ -8,9 +8,9 @@ import { registerToolTaskIfAvailable } from './task-support.js';
|
|
|
8
8
|
export const READ_FILE_TOOL = {
|
|
9
9
|
name: 'read',
|
|
10
10
|
title: 'Read File',
|
|
11
|
-
description: 'Read
|
|
12
|
-
'Use head
|
|
13
|
-
'For multiple files, use read_many
|
|
11
|
+
description: 'Read text file contents. ' +
|
|
12
|
+
'Use `head` to preview first N lines of large files. ' +
|
|
13
|
+
'For multiple files, use `read_many`.',
|
|
14
14
|
inputSchema: ReadFileInputSchema,
|
|
15
15
|
outputSchema: ReadFileOutputSchema,
|
|
16
16
|
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
|
@@ -5,28 +5,26 @@ import RE2 from 're2';
|
|
|
5
5
|
import safeRegex from 'safe-regex2';
|
|
6
6
|
import { DEFAULT_EXCLUDE_PATTERNS, MAX_TEXT_FILE_SIZE, PARALLEL_CONCURRENCY, } from '../lib/constants.js';
|
|
7
7
|
import { ErrorCode, formatUnknownErrorMessage, McpError, } from '../lib/errors.js';
|
|
8
|
-
import { globEntries } from '../lib/file-operations/
|
|
8
|
+
import { globEntries } from '../lib/file-operations/traversal.js';
|
|
9
9
|
import { atomicWriteFile, withAbort } from '../lib/fs-helpers.js';
|
|
10
|
-
import { validateExistingPath, validatePathForWrite
|
|
11
|
-
import { reportPeriodicProgress } from '../lib/
|
|
10
|
+
import { validateExistingPath, validatePathForWrite } from '../lib/paths.js';
|
|
11
|
+
import { reportPeriodicProgress } from '../lib/utils.js';
|
|
12
12
|
import { SearchAndReplaceInputSchema, SearchAndReplaceOutputSchema, } from '../schemas.js';
|
|
13
13
|
import { buildToolErrorResponse, buildToolResponse, createToolProgressSession, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, resolveFinalProgressCurrent, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
14
14
|
import { registerToolTaskIfAvailable } from './task-support.js';
|
|
15
15
|
export const SEARCH_AND_REPLACE_TOOL = {
|
|
16
16
|
name: 'search_and_replace',
|
|
17
17
|
title: 'Search and Replace',
|
|
18
|
-
description: '
|
|
19
|
-
'Replaces ALL occurrences
|
|
20
|
-
'
|
|
21
|
-
'
|
|
22
|
-
'Returns a unified diff of changes in `dryRun` mode. ' +
|
|
23
|
-
'Literal mode (default) matches exact text; `isRegex: true` enables RE2 regex with capture groups ($1, $2).',
|
|
18
|
+
description: 'Bulk search-and-replace across files matching a glob. ' +
|
|
19
|
+
'Replaces ALL occurrences per file (unlike `edit`: first only). ' +
|
|
20
|
+
'Always `dryRun:true` first \u2014 returns a unified diff. ' +
|
|
21
|
+
'Literal matching by default; `isRegex:true` enables RE2 with capture groups ($1, $2).',
|
|
24
22
|
inputSchema: SearchAndReplaceInputSchema,
|
|
25
23
|
outputSchema: SearchAndReplaceOutputSchema,
|
|
26
24
|
annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
|
|
27
25
|
taskSupport: 'optional',
|
|
28
26
|
gotchas: [
|
|
29
|
-
'
|
|
27
|
+
'Replaces ALL occurrences — not just the first. Use `edit` for single replacements.',
|
|
30
28
|
],
|
|
31
29
|
nuances: [
|
|
32
30
|
'Changed-file sample and failure sample are capped/truncated in output.',
|