From dfdb42212277605028665b514fda6dd4a0730193 Mon Sep 17 00:00:00 2001 From: majiayu000 <1835304752@qq.com> Date: Sat, 4 Apr 2026 01:08:05 +0800 Subject: [PATCH 1/3] fix(filesystem): wait for roots before handling tool calls Add a Promise-based gate that blocks tool handlers until allowedDirectories are initialized. This prevents a race condition where tool calls arrive before oninitialized finishes fetching roots, causing false "Access denied" errors. If CLI args already provide directories the gate opens immediately. Otherwise it opens when oninitialized completes (via try/finally). Fixes #3204 Signed-off-by: majiayu000 <1835304752@qq.com> --- src/filesystem/index.ts | 61 +++++++++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 7b67e63e58..026182ea1e 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -92,6 +92,16 @@ allowedDirectories = accessibleDirectories; // Initialize the global allowedDirectories in lib.ts setAllowedDirectories(allowedDirectories); +// Gate to block tool handlers until roots/directories are initialized. +// Without this, tool calls can race ahead of oninitialized and see empty allowedDirectories. +let resolveRootsReady: () => void; +const rootsReady = new Promise((resolve) => { + resolveRootsReady = resolve; +}); +if (allowedDirectories.length > 0) { + resolveRootsReady!(); +} + // Schema definitions const ReadTextFileArgsSchema = z.object({ path: z.string(), @@ -189,6 +199,7 @@ async function readFileAsBase64Stream(filePath: string): Promise { // read_file (deprecated) and read_text_file const readTextFileHandler = async (args: z.infer) => { + await rootsReady; const validPath = await validatePath(args.path); if (args.head && args.tail) { @@ -265,6 +276,7 @@ server.registerTool( annotations: { readOnlyHint: true } }, async (args: z.infer) => { + await rootsReady; const validPath = await validatePath(args.path); const extension = path.extname(validPath).toLowerCase(); const mimeTypes: Record = { @@ -316,6 +328,7 @@ server.registerTool( annotations: { readOnlyHint: true } }, async (args: z.infer) => { + await rootsReady; const results = await Promise.all( args.paths.map(async (filePath: string) => { try { @@ -352,6 +365,7 @@ server.registerTool( annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: true } }, async (args: z.infer) => { + await rootsReady; const validPath = await validatePath(args.path); await writeFileContent(validPath, args.content); const text = `Successfully wrote to ${args.path}`; @@ -382,6 +396,7 @@ server.registerTool( annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true } }, async (args: z.infer) => { + await rootsReady; const validPath = await validatePath(args.path); const result = await applyFileEdits(validPath, args.edits, args.dryRun); return { @@ -407,6 +422,7 @@ server.registerTool( annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false } }, async (args: z.infer) => { + await rootsReady; const validPath = await validatePath(args.path); await fs.mkdir(validPath, { recursive: true }); const text = `Successfully created directory ${args.path}`; @@ -433,6 +449,7 @@ server.registerTool( annotations: { readOnlyHint: true } }, async (args: z.infer) => { + await rootsReady; const validPath = await validatePath(args.path); const entries = await fs.readdir(validPath, { withFileTypes: true }); const formatted = entries @@ -462,6 +479,7 @@ server.registerTool( annotations: { readOnlyHint: true } }, async (args: z.infer) => { + await rootsReady; const validPath = await validatePath(args.path); const entries = await fs.readdir(validPath, { withFileTypes: true }); @@ -541,6 +559,7 @@ server.registerTool( annotations: { readOnlyHint: true } }, async (args: z.infer) => { + await rootsReady; interface TreeEntry { name: string; type: 'file' | 'directory'; @@ -611,6 +630,7 @@ server.registerTool( annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true } }, async (args: z.infer) => { + await rootsReady; const validSourcePath = await validatePath(args.source); const validDestPath = await validatePath(args.destination); await fs.rename(validSourcePath, validDestPath); @@ -642,6 +662,7 @@ server.registerTool( annotations: { readOnlyHint: true } }, async (args: z.infer) => { + await rootsReady; const validPath = await validatePath(args.path); const results = await searchFilesWithValidation(validPath, args.pattern, allowedDirectories, { excludePatterns: args.excludePatterns }); const text = results.length > 0 ? results.join("\n") : "No matches found"; @@ -668,6 +689,7 @@ server.registerTool( annotations: { readOnlyHint: true } }, async (args: z.infer) => { + await rootsReady; const validPath = await validatePath(args.path); const info = await getFileStats(validPath); const text = Object.entries(info) @@ -694,6 +716,7 @@ server.registerTool( annotations: { readOnlyHint: true } }, async () => { + await rootsReady; const text = `Allowed directories:\n${allowedDirectories.join('\n')}`; return { content: [{ type: "text" as const, text }], @@ -729,25 +752,29 @@ server.server.setNotificationHandler(RootsListChangedNotificationSchema, async ( // Handles post-initialization setup, specifically checking for and fetching MCP roots. server.server.oninitialized = async () => { - const clientCapabilities = server.server.getClientCapabilities(); - - if (clientCapabilities?.roots) { - try { - const response = await server.server.listRoots(); - if (response && 'roots' in response) { - await updateAllowedDirectoriesFromRoots(response.roots); - } else { - console.error("Client returned no roots set, keeping current settings"); + try { + const clientCapabilities = server.server.getClientCapabilities(); + + if (clientCapabilities?.roots) { + try { + const response = await server.server.listRoots(); + if (response && 'roots' in response) { + await updateAllowedDirectoriesFromRoots(response.roots); + } else { + console.error("Client returned no roots set, keeping current settings"); + } + } catch (error) { + console.error("Failed to request initial roots from client:", error instanceof Error ? error.message : String(error)); + } + } else { + if (allowedDirectories.length > 0) { + console.error("Client does not support MCP Roots, using allowed directories set from server args:", allowedDirectories); + }else{ + throw new Error(`Server cannot operate: No allowed directories available. Server was started without command-line directories and client either does not support MCP roots protocol or provided empty roots. Please either: 1) Start server with directory arguments, or 2) Use a client that supports MCP roots protocol and provides valid root directories.`); } - } catch (error) { - console.error("Failed to request initial roots from client:", error instanceof Error ? error.message : String(error)); - } - } else { - if (allowedDirectories.length > 0) { - console.error("Client does not support MCP Roots, using allowed directories set from server args:", allowedDirectories); - }else{ - throw new Error(`Server cannot operate: No allowed directories available. Server was started without command-line directories and client either does not support MCP roots protocol or provided empty roots. Please either: 1) Start server with directory arguments, or 2) Use a client that supports MCP roots protocol and provides valid root directories.`); } + } finally { + resolveRootsReady!(); } }; From 98e28d7ee25f4ee5b3e273121d14e75454810f62 Mon Sep 17 00:00:00 2001 From: majiayu000 <1835304752@qq.com> Date: Sat, 4 Apr 2026 01:14:21 +0800 Subject: [PATCH 2/3] fix(filesystem): extract roots gate with timeout, add tests Extract the inline Promise gate into roots-gate.ts with a configurable timeout (default 10s) to prevent indefinite hangs when oninitialized never fires. Add unit tests for immediate resolution, delayed resolution, timeout rejection, and concurrent waiters. Signed-off-by: majiayu000 <1835304752@qq.com> --- src/filesystem/__tests__/roots-gate.test.ts | 34 ++++++++++++++++++ src/filesystem/index.ts | 38 ++++++++++----------- src/filesystem/roots-gate.ts | 36 +++++++++++++++++++ 3 files changed, 88 insertions(+), 20 deletions(-) create mode 100644 src/filesystem/__tests__/roots-gate.test.ts create mode 100644 src/filesystem/roots-gate.ts diff --git a/src/filesystem/__tests__/roots-gate.test.ts b/src/filesystem/__tests__/roots-gate.test.ts new file mode 100644 index 0000000000..fa06bab88e --- /dev/null +++ b/src/filesystem/__tests__/roots-gate.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { createRootsGate } from '../roots-gate.js'; + +describe('createRootsGate', () => { + it('resolves immediately when gate is pre-resolved', async () => { + const gate = createRootsGate(); + gate.resolve(); + await expect(gate.waitForReady()).resolves.toBeUndefined(); + }); + + it('resolves after a delay when resolve is called later', async () => { + const gate = createRootsGate(); + setTimeout(() => gate.resolve(), 50); + await expect(gate.waitForReady()).resolves.toBeUndefined(); + }); + + it('rejects with timeout when gate is never resolved', async () => { + const gate = createRootsGate(100); + await expect(gate.waitForReady()).rejects.toThrow( + 'Roots initialization timed out after 100ms' + ); + }); + + it('resolves all concurrent waiters together', async () => { + const gate = createRootsGate(); + const results = Promise.all([ + gate.waitForReady(), + gate.waitForReady(), + gate.waitForReady(), + ]); + gate.resolve(); + await expect(results).resolves.toEqual([undefined, undefined, undefined]); + }); +}); diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 026182ea1e..8e36fd42d2 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -14,6 +14,7 @@ import { z } from "zod"; import { minimatch } from "minimatch"; import { normalizePath, expandHome } from './path-utils.js'; import { getValidRootDirectories } from './roots-utils.js'; +import { createRootsGate } from './roots-gate.js'; import { // Function imports formatSize, @@ -94,12 +95,9 @@ setAllowedDirectories(allowedDirectories); // Gate to block tool handlers until roots/directories are initialized. // Without this, tool calls can race ahead of oninitialized and see empty allowedDirectories. -let resolveRootsReady: () => void; -const rootsReady = new Promise((resolve) => { - resolveRootsReady = resolve; -}); +const rootsGate = createRootsGate(); if (allowedDirectories.length > 0) { - resolveRootsReady!(); + rootsGate.resolve(); } // Schema definitions @@ -199,7 +197,7 @@ async function readFileAsBase64Stream(filePath: string): Promise { // read_file (deprecated) and read_text_file const readTextFileHandler = async (args: z.infer) => { - await rootsReady; + await rootsGate.waitForReady(); const validPath = await validatePath(args.path); if (args.head && args.tail) { @@ -276,7 +274,7 @@ server.registerTool( annotations: { readOnlyHint: true } }, async (args: z.infer) => { - await rootsReady; + await rootsGate.waitForReady(); const validPath = await validatePath(args.path); const extension = path.extname(validPath).toLowerCase(); const mimeTypes: Record = { @@ -328,7 +326,7 @@ server.registerTool( annotations: { readOnlyHint: true } }, async (args: z.infer) => { - await rootsReady; + await rootsGate.waitForReady(); const results = await Promise.all( args.paths.map(async (filePath: string) => { try { @@ -365,7 +363,7 @@ server.registerTool( annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: true } }, async (args: z.infer) => { - await rootsReady; + await rootsGate.waitForReady(); const validPath = await validatePath(args.path); await writeFileContent(validPath, args.content); const text = `Successfully wrote to ${args.path}`; @@ -396,7 +394,7 @@ server.registerTool( annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true } }, async (args: z.infer) => { - await rootsReady; + await rootsGate.waitForReady(); const validPath = await validatePath(args.path); const result = await applyFileEdits(validPath, args.edits, args.dryRun); return { @@ -422,7 +420,7 @@ server.registerTool( annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false } }, async (args: z.infer) => { - await rootsReady; + await rootsGate.waitForReady(); const validPath = await validatePath(args.path); await fs.mkdir(validPath, { recursive: true }); const text = `Successfully created directory ${args.path}`; @@ -449,7 +447,7 @@ server.registerTool( annotations: { readOnlyHint: true } }, async (args: z.infer) => { - await rootsReady; + await rootsGate.waitForReady(); const validPath = await validatePath(args.path); const entries = await fs.readdir(validPath, { withFileTypes: true }); const formatted = entries @@ -479,7 +477,7 @@ server.registerTool( annotations: { readOnlyHint: true } }, async (args: z.infer) => { - await rootsReady; + await rootsGate.waitForReady(); const validPath = await validatePath(args.path); const entries = await fs.readdir(validPath, { withFileTypes: true }); @@ -559,7 +557,7 @@ server.registerTool( annotations: { readOnlyHint: true } }, async (args: z.infer) => { - await rootsReady; + await rootsGate.waitForReady(); interface TreeEntry { name: string; type: 'file' | 'directory'; @@ -630,7 +628,7 @@ server.registerTool( annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true } }, async (args: z.infer) => { - await rootsReady; + await rootsGate.waitForReady(); const validSourcePath = await validatePath(args.source); const validDestPath = await validatePath(args.destination); await fs.rename(validSourcePath, validDestPath); @@ -662,7 +660,7 @@ server.registerTool( annotations: { readOnlyHint: true } }, async (args: z.infer) => { - await rootsReady; + await rootsGate.waitForReady(); const validPath = await validatePath(args.path); const results = await searchFilesWithValidation(validPath, args.pattern, allowedDirectories, { excludePatterns: args.excludePatterns }); const text = results.length > 0 ? results.join("\n") : "No matches found"; @@ -689,7 +687,7 @@ server.registerTool( annotations: { readOnlyHint: true } }, async (args: z.infer) => { - await rootsReady; + await rootsGate.waitForReady(); const validPath = await validatePath(args.path); const info = await getFileStats(validPath); const text = Object.entries(info) @@ -716,7 +714,7 @@ server.registerTool( annotations: { readOnlyHint: true } }, async () => { - await rootsReady; + await rootsGate.waitForReady(); const text = `Allowed directories:\n${allowedDirectories.join('\n')}`; return { content: [{ type: "text" as const, text }], @@ -769,12 +767,12 @@ server.server.oninitialized = async () => { } else { if (allowedDirectories.length > 0) { console.error("Client does not support MCP Roots, using allowed directories set from server args:", allowedDirectories); - }else{ + } else { throw new Error(`Server cannot operate: No allowed directories available. Server was started without command-line directories and client either does not support MCP roots protocol or provided empty roots. Please either: 1) Start server with directory arguments, or 2) Use a client that supports MCP roots protocol and provides valid root directories.`); } } } finally { - resolveRootsReady!(); + rootsGate.resolve(); } }; diff --git a/src/filesystem/roots-gate.ts b/src/filesystem/roots-gate.ts new file mode 100644 index 0000000000..15b93b964b --- /dev/null +++ b/src/filesystem/roots-gate.ts @@ -0,0 +1,36 @@ +/** + * Promise-based readiness gate that blocks tool handlers until roots/directories + * are initialized. Prevents race conditions where tool calls arrive before + * oninitialized has finished loading roots. + */ + +export interface RootsGate { + promise: Promise; + resolve: () => void; + waitForReady: () => Promise; +} + +export function createRootsGate(timeoutMs: number = 10000): RootsGate { + let resolve: () => void; + const promise = new Promise((res) => { + resolve = res; + }); + + function waitForReady(): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`Roots initialization timed out after ${timeoutMs}ms`)), + timeoutMs + ) + ), + ]); + } + + return { + promise, + resolve: resolve!, + waitForReady, + }; +} From 9fdfa51e37d33d90004f621a32808ae44aa8b47e Mon Sep 17 00:00:00 2001 From: majiayu000 <1835304752@qq.com> Date: Tue, 21 Apr 2026 23:57:10 +0800 Subject: [PATCH 3/3] fix(filesystem): clear roots gate timeout watchers Clear per-waiter timeout handles once roots initialization completes so ready gates do not leave pending timers behind. Add an explicit post-resolve test and narrow the gate API to the methods callers actually use. Signed-off-by: majiayu000 <1835304752@qq.com> --- src/filesystem/__tests__/roots-gate.test.ts | 7 +++++ src/filesystem/roots-gate.ts | 31 ++++++++++++--------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/filesystem/__tests__/roots-gate.test.ts b/src/filesystem/__tests__/roots-gate.test.ts index fa06bab88e..2bb9dc00bd 100644 --- a/src/filesystem/__tests__/roots-gate.test.ts +++ b/src/filesystem/__tests__/roots-gate.test.ts @@ -8,6 +8,13 @@ describe('createRootsGate', () => { await expect(gate.waitForReady()).resolves.toBeUndefined(); }); + it('resolves immediately when called after the gate has already resolved', async () => { + const gate = createRootsGate(); + gate.resolve(); + await expect(gate.waitForReady()).resolves.toBeUndefined(); + await expect(gate.waitForReady()).resolves.toBeUndefined(); + }); + it('resolves after a delay when resolve is called later', async () => { const gate = createRootsGate(); setTimeout(() => gate.resolve(), 50); diff --git a/src/filesystem/roots-gate.ts b/src/filesystem/roots-gate.ts index 15b93b964b..aed0de89d3 100644 --- a/src/filesystem/roots-gate.ts +++ b/src/filesystem/roots-gate.ts @@ -5,32 +5,37 @@ */ export interface RootsGate { - promise: Promise; resolve: () => void; waitForReady: () => Promise; } export function createRootsGate(timeoutMs: number = 10000): RootsGate { - let resolve: () => void; + let resolve!: () => void; const promise = new Promise((res) => { resolve = res; }); function waitForReady(): Promise { - return Promise.race([ - promise, - new Promise((_, reject) => - setTimeout( - () => reject(new Error(`Roots initialization timed out after ${timeoutMs}ms`)), - timeoutMs - ) - ), - ]); + return new Promise((resolveWait, rejectWait) => { + const timer = setTimeout(() => { + rejectWait(new Error(`Roots initialization timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + promise.then( + () => { + clearTimeout(timer); + resolveWait(); + }, + (error) => { + clearTimeout(timer); + rejectWait(error); + } + ); + }); } return { - promise, - resolve: resolve!, + resolve, waitForReady, }; }