@j0hanz/filesystem-mcp 1.13.2 → 1.14.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.
Files changed (74) hide show
  1. package/README.md +162 -145
  2. package/dist/cli.js +2 -2
  3. package/dist/completions.js +54 -51
  4. package/dist/config.d.ts +13 -14
  5. package/dist/config.js +12 -12
  6. package/dist/index.js +1 -1
  7. package/dist/lib/abort.d.ts +7 -0
  8. package/dist/lib/abort.js +81 -0
  9. package/dist/lib/constants.d.ts +3 -1
  10. package/dist/lib/constants.js +8 -2
  11. package/dist/lib/errors.d.ts +7 -3
  12. package/dist/lib/errors.js +64 -41
  13. package/dist/lib/file-operations/core.d.ts +3 -3
  14. package/dist/lib/file-operations/core.js +23 -20
  15. package/dist/lib/file-operations/metadata.d.ts +2 -2
  16. package/dist/lib/file-operations/metadata.js +69 -22
  17. package/dist/lib/file-operations/search.d.ts +0 -1
  18. package/dist/lib/file-operations/search.js +87 -95
  19. package/dist/lib/file-operations/traversal.js +13 -15
  20. package/dist/lib/fs-helpers.d.ts +3 -10
  21. package/dist/lib/fs-helpers.js +29 -108
  22. package/dist/lib/globs.d.ts +2 -0
  23. package/dist/lib/globs.js +19 -0
  24. package/dist/lib/logger.d.ts +28 -0
  25. package/dist/lib/logger.js +91 -0
  26. package/dist/lib/observability.d.ts +7 -0
  27. package/dist/lib/observability.js +19 -9
  28. package/dist/lib/paths.js +55 -55
  29. package/dist/lib/resource-store.js +4 -4
  30. package/dist/lib/utils.d.ts +0 -12
  31. package/dist/lib/utils.js +0 -13
  32. package/dist/lib/zod-codecs.d.ts +2 -0
  33. package/dist/lib/zod-codecs.js +18 -0
  34. package/dist/pkg-info.d.ts +1 -0
  35. package/dist/pkg-info.js +2 -2
  36. package/dist/prompts.js +3 -3
  37. package/dist/resources/generated-instructions.js +41 -41
  38. package/dist/resources/tool-catalog.js +33 -58
  39. package/dist/resources/tool-info.d.ts +0 -1
  40. package/dist/resources/tool-info.js +44 -67
  41. package/dist/resources/workflows.js +47 -19
  42. package/dist/resources.d.ts +1 -1
  43. package/dist/resources.js +4 -4
  44. package/dist/schemas.d.ts +185 -465
  45. package/dist/schemas.js +174 -206
  46. package/dist/server/bootstrap.d.ts +12 -11
  47. package/dist/server/bootstrap.js +95 -86
  48. package/dist/server/roots-manager.d.ts +5 -2
  49. package/dist/server/roots-manager.js +9 -7
  50. package/dist/server/task-store.d.ts +10 -0
  51. package/dist/server/task-store.js +73 -0
  52. package/dist/tools/apply-patch.js +39 -20
  53. package/dist/tools/calculate-hash.js +14 -27
  54. package/dist/tools/create-directory.js +11 -9
  55. package/dist/tools/delete-file.js +19 -19
  56. package/dist/tools/diff-files.js +16 -18
  57. package/dist/tools/edit-file.js +11 -5
  58. package/dist/tools/list-directory.js +16 -21
  59. package/dist/tools/move-file.js +105 -100
  60. package/dist/tools/read-multiple.js +15 -10
  61. package/dist/tools/read.js +6 -7
  62. package/dist/tools/replace-in-files.js +76 -115
  63. package/dist/tools/roots.js +3 -7
  64. package/dist/tools/search-content.js +158 -203
  65. package/dist/tools/search-files.js +59 -50
  66. package/dist/tools/shared.d.ts +10 -0
  67. package/dist/tools/shared.js +105 -36
  68. package/dist/tools/stat-many.js +15 -9
  69. package/dist/tools/stat.js +6 -6
  70. package/dist/tools/task-support.d.ts +10 -9
  71. package/dist/tools/task-support.js +94 -23
  72. package/dist/tools/tree.js +4 -4
  73. package/dist/tools/write-file.js +11 -12
  74. package/package.json +10 -9
@@ -1,14 +1,15 @@
1
- import * as http from 'node:http';
2
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
- import type { LoggingLevel } from '@modelcontextprotocol/sdk/types.js';
4
- export interface ServerOptions {
5
- allowCwd?: boolean;
6
- cliAllowedDirs?: string[];
7
- }
8
- export interface LoggingState {
9
- minimumLevel: LoggingLevel;
10
- }
11
- export declare function logToMcp(server: McpServer | undefined, level: LoggingLevel, data: string, minLevel?: LoggingLevel): void;
2
+ import { type Server } from 'node:http';
3
+ import { type LoggingState } from '../lib/logger.js';
4
+ import { type ServerOptions } from './roots-manager.js';
5
+ export declare const activeServers: Map<string, {
6
+ server: McpServer;
7
+ loggingState: LoggingState;
8
+ }>;
9
+ export declare let stdioServer: {
10
+ server: McpServer;
11
+ loggingState: LoggingState;
12
+ } | undefined;
12
13
  export declare function createServer(options?: ServerOptions): Promise<McpServer>;
13
14
  export declare function startServer(server: McpServer): Promise<void>;
14
- export declare function startHttpServer(port: number, options: ServerOptions): Promise<http.Server>;
15
+ export declare function startHttpServer(port: number, options: ServerOptions): Promise<Server>;
@@ -1,16 +1,17 @@
1
- import * as fs from 'node:fs/promises';
2
- import * as http from 'node:http';
3
- import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
4
- import { InMemoryTaskMessageQueue, InMemoryTaskStore, } from '@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js';
1
+ import { InMemoryTaskMessageQueue } from '@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js';
5
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
4
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
8
5
  import { isInitializeRequest, LATEST_PROTOCOL_VERSION, SetLevelRequestSchema, SUPPORTED_PROTOCOL_VERSIONS, } from '@modelcontextprotocol/sdk/types.js';
6
+ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
7
+ import { channel } from 'node:diagnostics_channel';
8
+ import { readFile } from 'node:fs/promises';
9
+ import { createServer as createHttpServer, } from 'node:http';
9
10
  import { DEFAULT_LOG_LEVEL, parseEnvInt } from '../lib/constants.js';
10
11
  import { formatUnknownErrorMessage } from '../lib/errors.js';
12
+ import { createLoggingState, Logger, logToMcp, SessionContext, } from '../lib/logger.js';
11
13
  import { withAllowedDirectoriesState } from '../lib/paths.js';
12
14
  import { createInMemoryResourceStore } from '../lib/resource-store.js';
13
- import { isRecord } from '../lib/utils.js';
14
15
  import { registerCompletions } from '../completions.js';
15
16
  import { pkgInfo } from '../pkg-info.js';
16
17
  import { registerAnalyzePathPrompt, registerCompareFilesPrompt, registerGetHelpPrompt, registerGetToolHelpPrompt, } from '../prompts.js';
@@ -19,6 +20,7 @@ import { buildServerInstructions } from '../resources/generated-instructions.js'
19
20
  import { registerAllTools } from '../tools.js';
20
21
  import { withDefaultIcons } from '../tools/shared.js';
21
22
  import { RootsManager } from './roots-manager.js';
23
+ import { createTaskStore } from './task-store.js';
22
24
  let cachedTaskToolSupport;
23
25
  function detectTaskToolSupport() {
24
26
  if (cachedTaskToolSupport !== undefined) {
@@ -48,6 +50,7 @@ function buildServerCapabilities(options = {}) {
48
50
  tools: {},
49
51
  prompts: options.enablePromptListChanged ? { listChanged: true } : {},
50
52
  completions: {},
53
+ extensions: {},
51
54
  };
52
55
  if (options.enableTaskToolRequests) {
53
56
  // NOTE: enabling task tool requests requires the caller to configure
@@ -64,45 +67,30 @@ function buildServerCapabilities(options = {}) {
64
67
  function supportsTaskToolRequests() {
65
68
  return detectTaskToolSupport();
66
69
  }
67
- const MCP_LOGGER_NAME = 'filesystem-mcp';
68
- const LOG_LEVEL_ORDER = {
69
- debug: 0,
70
- info: 1,
71
- notice: 2,
72
- warning: 3,
73
- error: 4,
74
- critical: 5,
75
- alert: 6,
76
- emergency: 7,
77
- };
78
- function createLoggingState(minimumLevel = 'debug') {
79
- return { minimumLevel };
80
- }
81
- function canSendMcpLogs(server) {
82
- const capabilities = server.server.getClientCapabilities();
83
- if (!isRecord(capabilities))
84
- return false;
85
- if (!('logging' in capabilities))
86
- return false;
87
- return !!capabilities['logging'];
70
+ // Global map of all active servers by sessionId for routing logs
71
+ export const activeServers = new Map();
72
+ // For stdio (single session without a specific ID)
73
+ export let stdioServer;
74
+ function stringifyData(data) {
75
+ if (!data)
76
+ return '';
77
+ return ` ${typeof data === 'string' ? data : JSON.stringify(data)}`;
88
78
  }
89
- export function logToMcp(server, level, data, minLevel = 'debug') {
90
- if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[minLevel]) {
91
- return;
79
+ channel('filesystem-mcp:log').subscribe((message) => {
80
+ const event = message;
81
+ const target = event.sessionId
82
+ ? activeServers.get(event.sessionId)
83
+ : stdioServer;
84
+ const dataStr = stringifyData(event.data);
85
+ if (target) {
86
+ logToMcp(target.server, event.level, `${event.message}${dataStr}`, target.loggingState.minimumLevel);
92
87
  }
93
- if (!server || !canSendMcpLogs(server)) {
94
- console.error(data);
95
- return;
88
+ else {
89
+ // Fallback if no server
90
+ const fullMsg = `${event.message}${dataStr}`;
91
+ console.error(`[${event.level.toUpperCase()}] ${fullMsg}`);
96
92
  }
97
- const params = {
98
- level,
99
- logger: MCP_LOGGER_NAME,
100
- data,
101
- };
102
- void server.sendLoggingMessage(params).catch((error) => {
103
- console.error(`Failed to send MCP log: ${level} | ${data}`, formatUnknownErrorMessage(error));
104
- });
105
- }
93
+ });
106
94
  const { version: SERVER_VERSION, description: SERVER_DESCRIPTION, homepage: SERVER_HOMEPAGE, } = pkgInfo;
107
95
  const rootsManagers = new WeakMap();
108
96
  function getRootsManager(server) {
@@ -119,7 +107,7 @@ async function getLocalIconInfo() {
119
107
  for (const candidate of candidates) {
120
108
  try {
121
109
  const iconPath = new URL(candidate, import.meta.url);
122
- const buffer = await fs.readFile(iconPath);
110
+ const buffer = await readFile(iconPath);
123
111
  return {
124
112
  src: `data:${mime};base64,${buffer.toString('base64')}`,
125
113
  mimeType: mime,
@@ -144,7 +132,7 @@ export async function createServer(options = {}) {
144
132
  };
145
133
  if (taskToolSupport) {
146
134
  // Enabling task tool support requires configuring a task store and message queue on the server config. We use in-memory implementations from the SDK which auto-evict tasks after their TTL expires (via setTimeout). Suitable for both stdio and HTTP sessions.
147
- serverConfig.taskStore = new InMemoryTaskStore();
135
+ serverConfig.taskStore = createTaskStore();
148
136
  serverConfig.taskMessageQueue = new InMemoryTaskMessageQueue();
149
137
  }
150
138
  if (serverInstructions) {
@@ -163,10 +151,15 @@ export async function createServer(options = {}) {
163
151
  const loggingState = createLoggingState(DEFAULT_LOG_LEVEL);
164
152
  const rootsManager = new RootsManager(options, loggingState);
165
153
  rootsManagers.set(server, rootsManager);
154
+ // Subscribe to Logger channel if not already done, but we need to route based on session or fallback to this server if it's stdio.
155
+ // Wait, in stdio there's only one server. In HTTP there are multiple.
166
156
  server.server.setRequestHandler(SetLevelRequestSchema, (req) => {
167
157
  loggingState.minimumLevel = req.params.level;
158
+ Logger.notice(`Log level set to ${req.params.level}`);
168
159
  return {};
169
160
  });
161
+ // Track stdio server by default, or it will be overwritten per HTTP session later
162
+ stdioServer ??= { server, loggingState };
170
163
  registerInstructionResource(server, serverInstructions, localIcon);
171
164
  registerToolCatalogResource(server, localIcon);
172
165
  registerWorkflowGuideResource(server, localIcon);
@@ -257,6 +250,10 @@ async function createHttpSession(options, sessions, negotiatedProtocolVersion) {
257
250
  transport,
258
251
  negotiatedProtocolVersion,
259
252
  });
253
+ activeServers.set(sessionId, {
254
+ server: mcpServer,
255
+ loggingState: rootsManager['loggingState'],
256
+ });
260
257
  rootsManager.logMissingDirectoriesIfNeeded(mcpServer);
261
258
  },
262
259
  });
@@ -264,10 +261,11 @@ async function createHttpSession(options, sessions, negotiatedProtocolVersion) {
264
261
  const { sessionId } = transport;
265
262
  if (sessionId) {
266
263
  sessions.delete(sessionId);
264
+ activeServers.delete(sessionId);
267
265
  }
268
266
  rootsManager.destroy();
269
267
  mcpServer.close().catch((err) => {
270
- console.error('[HTTP] Error closing MCP server:', formatUnknownErrorMessage(err));
268
+ Logger.error('[HTTP] Error closing MCP server:', formatUnknownErrorMessage(err));
271
269
  });
272
270
  };
273
271
  await mcpServer.connect(transport);
@@ -368,7 +366,12 @@ function discardRequestBody(req) {
368
366
  req.resume();
369
367
  }
370
368
  async function handleSessionTransportRequest(session, req, res, body) {
371
- await withAllowedDirectoriesState(session.rootsManager.getAllowedDirectoriesState(), () => session.transport.handleRequest(req, res, body));
369
+ const store = session.transport.sessionId
370
+ ? { sessionId: session.transport.sessionId }
371
+ : {};
372
+ await SessionContext.run(store, async () => {
373
+ await withAllowedDirectoriesState(session.rootsManager.getAllowedDirectoriesState(), () => session.transport.handleRequest(req, res, body));
374
+ });
372
375
  }
373
376
  function getSessionOrRespondNotFound(sessions, sessionId, res) {
374
377
  const session = sessions.get(sessionId);
@@ -396,6 +399,47 @@ export async function startHttpServer(port, options) {
396
399
  const sessions = new Map();
397
400
  const httpHost = process.env['FILESYSTEM_MCP_HTTP_HOST'] ?? '127.0.0.1';
398
401
  assertHttpBindingSecurity(httpHost);
402
+ async function handlePostRequest(req, res, sessionId) {
403
+ if (sessionId) {
404
+ const session = getSessionOrRespondNotFound(sessions, sessionId, res);
405
+ if (!session) {
406
+ discardRequestBody(req);
407
+ return;
408
+ }
409
+ if (!ensureSessionProtocolVersion(req, res, session)) {
410
+ discardRequestBody(req);
411
+ return;
412
+ }
413
+ const body = await readRequestBody(req);
414
+ await handleSessionTransportRequest(session, req, res, body);
415
+ return;
416
+ }
417
+ const body = await readRequestBody(req);
418
+ if (isInitializeRequest(body)) {
419
+ const maxSessions = parseEnvInt('FILESYSTEM_MCP_MAX_HTTP_SESSIONS', 100, 1, 10_000);
420
+ if (sessions.size >= maxSessions) {
421
+ sendJsonRpcError(res, 503, JSON_RPC_SERVER_ERROR, 'Too many sessions');
422
+ return;
423
+ }
424
+ const session = await createHttpSession(options, sessions, resolveNegotiatedProtocolVersion(body.params.protocolVersion));
425
+ await handleSessionTransportRequest(session, req, res, body);
426
+ return;
427
+ }
428
+ sendJsonRpcError(res, 400, JSON_RPC_SERVER_ERROR, 'Bad Request: No valid session ID provided');
429
+ discardRequestBody(req);
430
+ }
431
+ async function handleGetDeleteRequest(req, res, sessionId) {
432
+ if (!sessionId) {
433
+ sendJsonRpcError(res, 400, JSON_RPC_SERVER_ERROR, 'Bad Request: Missing session ID');
434
+ return;
435
+ }
436
+ const session = getSessionOrRespondNotFound(sessions, sessionId, res);
437
+ if (!session)
438
+ return;
439
+ if (!ensureSessionProtocolVersion(req, res, session))
440
+ return;
441
+ await handleSessionTransportRequest(session, req, res);
442
+ }
399
443
  async function handleMcpRequest(req, res) {
400
444
  const { method } = req;
401
445
  const sessionId = getSessionId(req);
@@ -408,45 +452,10 @@ export async function startHttpServer(port, options) {
408
452
  return;
409
453
  try {
410
454
  if (method === 'POST') {
411
- if (sessionId) {
412
- const session = getSessionOrRespondNotFound(sessions, sessionId, res);
413
- if (!session) {
414
- discardRequestBody(req);
415
- return;
416
- }
417
- if (!ensureSessionProtocolVersion(req, res, session)) {
418
- discardRequestBody(req);
419
- return;
420
- }
421
- const body = await readRequestBody(req);
422
- await handleSessionTransportRequest(session, req, res, body);
423
- return;
424
- }
425
- const body = await readRequestBody(req);
426
- if (isInitializeRequest(body)) {
427
- const maxSessions = parseEnvInt('FILESYSTEM_MCP_MAX_HTTP_SESSIONS', 100, 1, 10_000);
428
- if (sessions.size >= maxSessions) {
429
- sendJsonRpcError(res, 503, JSON_RPC_SERVER_ERROR, 'Too many sessions');
430
- return;
431
- }
432
- const session = await createHttpSession(options, sessions, resolveNegotiatedProtocolVersion(body.params.protocolVersion));
433
- await handleSessionTransportRequest(session, req, res, body);
434
- return;
435
- }
436
- sendJsonRpcError(res, 400, JSON_RPC_SERVER_ERROR, 'Bad Request: No valid session ID provided');
437
- discardRequestBody(req);
455
+ await handlePostRequest(req, res, sessionId);
438
456
  }
439
457
  else if (method === 'GET' || method === 'DELETE') {
440
- if (!sessionId) {
441
- sendJsonRpcError(res, 400, JSON_RPC_SERVER_ERROR, 'Bad Request: Missing session ID');
442
- return;
443
- }
444
- const session = getSessionOrRespondNotFound(sessions, sessionId, res);
445
- if (!session)
446
- return;
447
- if (!ensureSessionProtocolVersion(req, res, session))
448
- return;
449
- await handleSessionTransportRequest(session, req, res);
458
+ await handleGetDeleteRequest(req, res, sessionId);
450
459
  }
451
460
  else {
452
461
  res.writeHead(405, {
@@ -472,17 +481,17 @@ export async function startHttpServer(port, options) {
472
481
  sendJsonRpcError(res, error.statusCode, rpcCode, error.message);
473
482
  return;
474
483
  }
475
- console.error('[HTTP] Error handling request:', formatUnknownErrorMessage(error));
484
+ Logger.error('[HTTP] Error handling request:', formatUnknownErrorMessage(error));
476
485
  if (!res.headersSent) {
477
486
  sendJsonRpcError(res, 500, JSON_RPC_INTERNAL_ERROR, 'Internal Server Error');
478
487
  }
479
488
  }
480
489
  }
481
- const httpServer = http.createServer((req, res) => {
490
+ const httpServer = createHttpServer((req, res) => {
482
491
  const urlPath = (req.url ?? '/').split('?')[0];
483
492
  if (urlPath === '/mcp') {
484
493
  handleMcpRequest(req, res).catch((err) => {
485
- console.error('[HTTP] Unhandled error in request handler:', formatUnknownErrorMessage(err));
494
+ Logger.error('[HTTP] Unhandled error in request handler:', formatUnknownErrorMessage(err));
486
495
  });
487
496
  }
488
497
  else {
@@ -493,7 +502,7 @@ export async function startHttpServer(port, options) {
493
502
  return new Promise((resolve, reject) => {
494
503
  httpServer.once('error', reject);
495
504
  httpServer.listen(port, httpHost, () => {
496
- console.error(`MCP HTTP server listening on ${httpHost}:${port}`);
505
+ Logger.info(`MCP HTTP server listening on ${httpHost}:${port}`);
497
506
  resolve(httpServer);
498
507
  });
499
508
  });
@@ -1,7 +1,10 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { type LoggingState } from '../lib/logger.js';
2
3
  import { type AllowedDirectoriesState } from '../lib/paths.js';
3
- import { type LoggingState } from './bootstrap.js';
4
- import type { ServerOptions } from './bootstrap.js';
4
+ export interface ServerOptions {
5
+ allowCwd?: boolean;
6
+ cliAllowedDirs?: string[];
7
+ }
5
8
  export declare class RootsManager {
6
9
  private _debouncedUpdate;
7
10
  private rootDirectories;
@@ -1,11 +1,11 @@
1
- import * as fs from 'node:fs/promises';
2
1
  import { InitializedNotificationSchema, RootsListChangedNotificationSchema, } from '@modelcontextprotocol/sdk/types.js';
2
+ import { realpath } from 'node:fs/promises';
3
3
  import { z } from 'zod';
4
+ import { assertNotAborted, createTimedAbortSignal, withAbort, } from '../lib/abort.js';
4
5
  import { formatUnknownErrorMessage } from '../lib/errors.js';
5
- import { assertNotAborted, createTimedAbortSignal, withAbort, } from '../lib/fs-helpers.js';
6
+ import { Logger, logToMcp } from '../lib/logger.js';
6
7
  import { getValidRootDirectories, isPathWithinDirectories, normalizePath, resolveAllowedDirectoriesState, setAllowedDirectoriesStateResolved, } from '../lib/paths.js';
7
8
  import { debounce, 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) {
@@ -22,7 +22,7 @@ const RootSchema = z.strictObject({
22
22
  uri: z.string(),
23
23
  name: z.string().optional(),
24
24
  });
25
- const RootsResponseSchema = z.object({
25
+ const RootsResponseSchema = z.strictObject({
26
26
  roots: z.array(RootSchema).optional(),
27
27
  });
28
28
  function isRoot(value) {
@@ -61,7 +61,7 @@ async function isRootWithinBaseline(normalizedRoot, baseline, signal) {
61
61
  }
62
62
  try {
63
63
  assertNotAborted(signal);
64
- const realPath = await withAbort(fs.realpath(normalizedRoot), signal);
64
+ const realPath = await withAbort(realpath(normalizedRoot), signal);
65
65
  const normalizedReal = normalizePath(realPath);
66
66
  return isPathWithinDirectories(normalizedReal, baseline);
67
67
  }
@@ -88,9 +88,10 @@ export class RootsManager {
88
88
  expanded: [],
89
89
  };
90
90
  clientInitialized = false;
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
91
+ // Guard concurrent root refreshes; if one is already running we queue one
92
+ // replay so the last-known state still gets applied after completion.
92
93
  updatingRoots = false;
93
- // If an update is in progress and a change arrives, we set this flag to ensure we run another update after completion to apply the latest state
94
+ // Tracks whether a roots change arrived while the previous refresh ran.
94
95
  pendingRootsUpdate = false;
95
96
  options;
96
97
  loggingState;
@@ -187,6 +188,7 @@ export class RootsManager {
187
188
  }
188
189
  finally {
189
190
  await this.recomputeAllowedDirectories();
191
+ Logger.info(`Roots updated: ${this.rootDirectories.length} root(s), ${this.allowedDirectoriesState.expanded.length} allowed dir(s)`);
190
192
  this.updatingRoots = false;
191
193
  // If a change arrived while we were running, apply it now.
192
194
  if (this.pendingRootsUpdate) {
@@ -0,0 +1,10 @@
1
+ import { InMemoryTaskStore } from '@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js';
2
+ import type { Result } from '@modelcontextprotocol/sdk/types.js';
3
+ export declare class ResultAwareInMemoryTaskStore extends InMemoryTaskStore {
4
+ private readonly cancelledResults;
5
+ getTaskResult(taskId: string, sessionId?: string): Promise<Result>;
6
+ storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result, sessionId?: string): Promise<void>;
7
+ updateTaskStatus(taskId: string, status: 'working' | 'input_required' | 'completed' | 'failed' | 'cancelled', statusMessage?: string, sessionId?: string): Promise<void>;
8
+ cleanup(): void;
9
+ }
10
+ export declare function createTaskStore(): ResultAwareInMemoryTaskStore;
@@ -0,0 +1,73 @@
1
+ import { InMemoryTaskStore } from '@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js';
2
+ import { ErrorCode } from '../lib/errors.js';
3
+ const DEFAULT_CANCELLED_STATUS_MESSAGE = 'Client cancelled task execution.';
4
+ function getTaskKey(taskId, sessionId) {
5
+ return `${sessionId ?? ''}:${taskId}`;
6
+ }
7
+ function buildCancelledTaskResult(statusMessage) {
8
+ return {
9
+ content: [
10
+ {
11
+ type: 'text',
12
+ text: `Error [${ErrorCode.CANCELLED}]: ${statusMessage ?? DEFAULT_CANCELLED_STATUS_MESSAGE}`,
13
+ },
14
+ ],
15
+ isError: true,
16
+ errorCode: ErrorCode.CANCELLED,
17
+ };
18
+ }
19
+ export class ResultAwareInMemoryTaskStore extends InMemoryTaskStore {
20
+ cancelledResults = new Map();
21
+ async getTaskResult(taskId, sessionId) {
22
+ try {
23
+ return await super.getTaskResult(taskId, sessionId);
24
+ }
25
+ catch (error) {
26
+ const task = await super.getTask(taskId, sessionId);
27
+ if (task?.status !== 'cancelled') {
28
+ throw error;
29
+ }
30
+ const key = getTaskKey(taskId, sessionId);
31
+ const existing = this.cancelledResults.get(key);
32
+ if (existing)
33
+ return existing;
34
+ const result = buildCancelledTaskResult(task.statusMessage);
35
+ this.cancelledResults.set(key, result);
36
+ return result;
37
+ }
38
+ }
39
+ async storeTaskResult(taskId, status, result, sessionId) {
40
+ try {
41
+ await super.storeTaskResult(taskId, status, result, sessionId);
42
+ if (status !== 'failed') {
43
+ this.cancelledResults.delete(getTaskKey(taskId, sessionId));
44
+ }
45
+ }
46
+ catch (error) {
47
+ const task = await super.getTask(taskId, sessionId);
48
+ if (task?.status !== 'cancelled') {
49
+ throw error;
50
+ }
51
+ this.cancelledResults.set(getTaskKey(taskId, sessionId), this.cancelledResults.get(getTaskKey(taskId, sessionId)) ?? result);
52
+ }
53
+ }
54
+ async updateTaskStatus(taskId, status, statusMessage, sessionId) {
55
+ await super.updateTaskStatus(taskId, status, statusMessage, sessionId);
56
+ const key = getTaskKey(taskId, sessionId);
57
+ if (status === 'cancelled') {
58
+ this.cancelledResults.set(key, this.cancelledResults.get(key) ??
59
+ buildCancelledTaskResult(statusMessage));
60
+ return;
61
+ }
62
+ if (status === 'completed' || status === 'failed') {
63
+ this.cancelledResults.delete(key);
64
+ }
65
+ }
66
+ cleanup() {
67
+ this.cancelledResults.clear();
68
+ super.cleanup();
69
+ }
70
+ }
71
+ export function createTaskStore() {
72
+ return new ResultAwareInMemoryTaskStore();
73
+ }
@@ -1,12 +1,14 @@
1
- import * as path from 'node:path';
2
1
  import { readFile, stat } from 'node:fs/promises';
2
+ import { basename, resolve } from 'node:path';
3
3
  import { applyPatch, parsePatch } from 'diff';
4
+ import { withAbort } from '../lib/abort.js';
4
5
  import { MAX_TEXT_FILE_SIZE, PARALLEL_CONCURRENCY } from '../lib/constants.js';
5
- import { ErrorCode, formatUnknownErrorMessage, McpError, } from '../lib/errors.js';
6
- import { atomicWriteFile, processInParallel, withAbort, } from '../lib/fs-helpers.js';
6
+ import { ErrorCode, McpError } from '../lib/errors.js';
7
+ import { atomicWriteFile, processInParallel } from '../lib/fs-helpers.js';
8
+ import { Logger } from '../lib/logger.js';
7
9
  import { assertAllowedFileAccess, validateExistingPath } from '../lib/paths.js';
8
10
  import { ApplyPatchInputSchema, ApplyPatchOutputSchema } from '../schemas.js';
9
- import { buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
11
+ import { buildStructuredError, buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
10
12
  import { registerToolTaskIfAvailable } from './task-support.js';
11
13
  export const APPLY_PATCH_TOOL = {
12
14
  name: 'apply_patch',
@@ -21,13 +23,12 @@ export const APPLY_PATCH_TOOL = {
21
23
  nuances: [
22
24
  'Multi-file patches use `path` as base directory; per-file results in `results[]`.',
23
25
  ],
24
- gotchas: ['Patch must include valid hunk headers; use `dryRun=true` first.'],
25
26
  taskSupport: 'forbidden',
26
27
  };
27
28
  function assertPatchTargetSizeWithinLimit(filePath, size, maxFileSize) {
28
29
  if (size <= maxFileSize)
29
30
  return;
30
- throw new McpError(ErrorCode.E_TOO_LARGE, `File too large for patch: ${filePath} (${size} bytes > ${maxFileSize} bytes).`, filePath, { size, maxFileSize });
31
+ throw new McpError(ErrorCode.TOO_LARGE, `File too large for patch (${size} bytes > ${maxFileSize} bytes).`, filePath, { size, maxFileSize });
31
32
  }
32
33
  function countStructuredPatchStats(diff) {
33
34
  let linesAdded = 0;
@@ -68,11 +69,15 @@ async function applyDiff(filePath, diff, options, signal) {
68
69
  return {
69
70
  path: validPath,
70
71
  applied: false,
71
- error: 'Patch application failed',
72
+ error: buildStructuredError(new McpError(ErrorCode.INVALID_INPUT, 'Patch application failed', validPath), ErrorCode.INVALID_INPUT, validPath),
72
73
  };
73
74
  }
74
75
  if (patched === content) {
75
- return { path: validPath, applied: false, error: 'Patch had no effect' };
76
+ return {
77
+ path: validPath,
78
+ applied: false,
79
+ error: buildStructuredError(new McpError(ErrorCode.INVALID_INPUT, 'Patch had no effect', validPath), ErrorCode.INVALID_INPUT, validPath),
80
+ };
76
81
  }
77
82
  const patchStats = countStructuredPatchStats(diff);
78
83
  if (!options.dryRun) {
@@ -88,10 +93,10 @@ async function processMultiFilePatch(basePath, parsed, options, signal) {
88
93
  return () => Promise.resolve({
89
94
  path: '<unknown>',
90
95
  applied: false,
91
- error: 'Missing file name in patch header',
96
+ error: buildStructuredError(new McpError(ErrorCode.INVALID_INPUT, 'Missing file name in patch header'), ErrorCode.INVALID_INPUT),
92
97
  });
93
98
  }
94
- const filePath = path.resolve(validBase, fileName);
99
+ const filePath = resolve(validBase, fileName);
95
100
  return async () => {
96
101
  try {
97
102
  const result = await applyDiff(filePath, diff, options, signal);
@@ -101,7 +106,7 @@ async function processMultiFilePatch(basePath, parsed, options, signal) {
101
106
  return {
102
107
  path: fileName,
103
108
  applied: false,
104
- error: formatUnknownErrorMessage(error),
109
+ error: buildStructuredError(error, ErrorCode.INVALID_INPUT, filePath),
105
110
  };
106
111
  }
107
112
  };
@@ -117,7 +122,17 @@ async function processMultiFilePatch(basePath, parsed, options, signal) {
117
122
  return acc;
118
123
  }, { applied: 0, hunks: 0, added: 0, removed: 0 });
119
124
  const label = options.dryRun ? ' (dry run)' : '';
125
+ if (totals.applied === 0) {
126
+ const failedPaths = results
127
+ .filter((r) => !r.applied)
128
+ .map((r) => r.path)
129
+ .join(', ');
130
+ throw new McpError(ErrorCode.INVALID_INPUT, `All ${parsed.length} patches failed${label}. Files: ${failedPaths}. Regenerate via diff_files.`);
131
+ }
120
132
  const text = `Applied ${totals.applied}/${parsed.length} file patches${label}`;
133
+ if (!options.dryRun) {
134
+ Logger.info(`apply_patch: ${basePath} (${totals.applied} file(s), +${totals.added}/-${totals.removed})`);
135
+ }
121
136
  return buildToolResponse(text, {
122
137
  ok: totals.applied === parsed.length,
123
138
  path: basePath,
@@ -130,13 +145,13 @@ async function processMultiFilePatch(basePath, parsed, options, signal) {
130
145
  }
131
146
  async function handleApplyPatch(args, signal) {
132
147
  if (!args.patch.trim()) {
133
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Patch content is empty.');
148
+ throw new McpError(ErrorCode.INVALID_INPUT, 'Patch content is empty.');
134
149
  }
135
150
  const fuzzFactor = args.fuzzFactor ?? 0;
136
151
  const parsed = parsePatch(args.patch);
137
152
  const hasHunks = parsed.some((p) => p.hunks.length > 0);
138
153
  if (!hasHunks) {
139
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Patch must include unified hunk headers (e.g., @@ -1,2 +1,2 @@).');
154
+ throw new McpError(ErrorCode.INVALID_INPUT, 'Patch must include unified hunk headers (@@ -n,n +n,n @@).');
140
155
  }
141
156
  const options = {
142
157
  dryRun: args.dryRun,
@@ -148,17 +163,20 @@ async function handleApplyPatch(args, signal) {
148
163
  }
149
164
  const diff = parsed[0];
150
165
  if (!diff) {
151
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'No patch content found.');
166
+ throw new McpError(ErrorCode.INVALID_INPUT, 'No patch content found.');
152
167
  }
153
168
  const result = await applyDiff(args.path, diff, options, signal);
154
169
  if (!result.applied) {
155
- throw new McpError(ErrorCode.E_INVALID_INPUT, result.error === 'Patch had no effect'
156
- ? 'Patch had no effect \u2014 the file content is unchanged after applying. The patch may not match the current file content. Generate a fresh patch via diff_files and retry.'
157
- : 'Patch application failed. The file content may have changed or patch context is insufficient. Generate a fresh patch via diff_files against the current file, then retry. If differences are minor, enable fuzzy matching with the fuzzFactor parameter.');
170
+ throw new McpError(ErrorCode.INVALID_INPUT, result.error?.message === 'Patch had no effect'
171
+ ? 'Patch had no effect. Content unchanged. Regenerate via diff_files.'
172
+ : 'Patch failed. Content may have changed. Regenerate via diff_files. For minor diffs, use fuzzFactor.');
158
173
  }
159
174
  const text = args.dryRun
160
175
  ? 'Dry run successful. Patch can be applied.'
161
176
  : `Successfully patched ${args.path}`;
177
+ if (!args.dryRun) {
178
+ Logger.info(`apply_patch: ${args.path} (+${result.linesAdded ?? 0}/-${result.linesRemoved ?? 0})`);
179
+ }
162
180
  return buildToolResponse(text, {
163
181
  ok: true,
164
182
  path: result.path,
@@ -172,19 +190,20 @@ export function registerApplyPatchTool(server, options = {}) {
172
190
  const handler = (args, extra) => executeToolWithDiagnostics({
173
191
  toolName: 'apply_patch',
174
192
  extra,
193
+ outputSchema: ApplyPatchOutputSchema,
175
194
  timedSignal: {},
176
195
  context: { path: args.path },
177
196
  run: (signal) => handleApplyPatch(args, signal),
178
- onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path),
197
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.UNKNOWN, args.path),
179
198
  });
180
199
  const wrappedHandler = wrapToolHandler(handler, {
181
200
  guard: options.isInitialized,
182
201
  progressMessage: (args) => {
183
- const name = path.basename(args.path);
202
+ const name = basename(args.path);
184
203
  return args.dryRun ? `🛠 patch: ${name} [dry run]` : `🛠 patch: ${name}`;
185
204
  },
186
205
  completionMessage: (args, result) => {
187
- const name = path.basename(args.path);
206
+ const name = basename(args.path);
188
207
  if (result.isError)
189
208
  return `🛠 patch: ${name} • failed`;
190
209
  const sc = result.structuredContent;