Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/filesystem/__tests__/roots-gate.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
57 changes: 41 additions & 16 deletions src/filesystem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -92,6 +93,13 @@ 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.
const rootsGate = createRootsGate();
if (allowedDirectories.length > 0) {
rootsGate.resolve();
}

// Schema definitions
const ReadTextFileArgsSchema = z.object({
path: z.string(),
Expand Down Expand Up @@ -189,6 +197,7 @@ async function readFileAsBase64Stream(filePath: string): Promise<string> {

// read_file (deprecated) and read_text_file
const readTextFileHandler = async (args: z.infer<typeof ReadTextFileArgsSchema>) => {
await rootsGate.waitForReady();
const validPath = await validatePath(args.path);

if (args.head && args.tail) {
Expand Down Expand Up @@ -265,6 +274,7 @@ server.registerTool(
annotations: { readOnlyHint: true }
},
async (args: z.infer<typeof ReadMediaFileArgsSchema>) => {
await rootsGate.waitForReady();
const validPath = await validatePath(args.path);
const extension = path.extname(validPath).toLowerCase();
const mimeTypes: Record<string, string> = {
Expand Down Expand Up @@ -316,6 +326,7 @@ server.registerTool(
annotations: { readOnlyHint: true }
},
async (args: z.infer<typeof ReadMultipleFilesArgsSchema>) => {
await rootsGate.waitForReady();
const results = await Promise.all(
args.paths.map(async (filePath: string) => {
try {
Expand Down Expand Up @@ -352,6 +363,7 @@ server.registerTool(
annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: true }
},
async (args: z.infer<typeof WriteFileArgsSchema>) => {
await rootsGate.waitForReady();
const validPath = await validatePath(args.path);
await writeFileContent(validPath, args.content);
const text = `Successfully wrote to ${args.path}`;
Expand Down Expand Up @@ -382,6 +394,7 @@ server.registerTool(
annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true }
},
async (args: z.infer<typeof EditFileArgsSchema>) => {
await rootsGate.waitForReady();
const validPath = await validatePath(args.path);
const result = await applyFileEdits(validPath, args.edits, args.dryRun);
return {
Expand All @@ -407,6 +420,7 @@ server.registerTool(
annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false }
},
async (args: z.infer<typeof CreateDirectoryArgsSchema>) => {
await rootsGate.waitForReady();
const validPath = await validatePath(args.path);
await fs.mkdir(validPath, { recursive: true });
const text = `Successfully created directory ${args.path}`;
Expand All @@ -433,6 +447,7 @@ server.registerTool(
annotations: { readOnlyHint: true }
},
async (args: z.infer<typeof ListDirectoryArgsSchema>) => {
await rootsGate.waitForReady();
const validPath = await validatePath(args.path);
const entries = await fs.readdir(validPath, { withFileTypes: true });
const formatted = entries
Expand Down Expand Up @@ -462,6 +477,7 @@ server.registerTool(
annotations: { readOnlyHint: true }
},
async (args: z.infer<typeof ListDirectoryWithSizesArgsSchema>) => {
await rootsGate.waitForReady();
const validPath = await validatePath(args.path);
const entries = await fs.readdir(validPath, { withFileTypes: true });

Expand Down Expand Up @@ -541,6 +557,7 @@ server.registerTool(
annotations: { readOnlyHint: true }
},
async (args: z.infer<typeof DirectoryTreeArgsSchema>) => {
await rootsGate.waitForReady();
interface TreeEntry {
name: string;
type: 'file' | 'directory';
Expand Down Expand Up @@ -611,6 +628,7 @@ server.registerTool(
annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true }
},
async (args: z.infer<typeof MoveFileArgsSchema>) => {
await rootsGate.waitForReady();
const validSourcePath = await validatePath(args.source);
const validDestPath = await validatePath(args.destination);
await fs.rename(validSourcePath, validDestPath);
Expand Down Expand Up @@ -642,6 +660,7 @@ server.registerTool(
annotations: { readOnlyHint: true }
},
async (args: z.infer<typeof SearchFilesArgsSchema>) => {
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";
Expand All @@ -668,6 +687,7 @@ server.registerTool(
annotations: { readOnlyHint: true }
},
async (args: z.infer<typeof GetFileInfoArgsSchema>) => {
await rootsGate.waitForReady();
const validPath = await validatePath(args.path);
const info = await getFileStats(validPath);
const text = Object.entries(info)
Expand All @@ -694,6 +714,7 @@ server.registerTool(
annotations: { readOnlyHint: true }
},
async () => {
await rootsGate.waitForReady();
const text = `Allowed directories:\n${allowedDirectories.join('\n')}`;
return {
content: [{ type: "text" as const, text }],
Expand Down Expand Up @@ -729,25 +750,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);
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 {
console.error("Client returned no roots set, keeping current settings");
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 {
rootsGate.resolve();
}
};

Expand Down
36 changes: 36 additions & 0 deletions src/filesystem/roots-gate.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
resolve: () => void;
waitForReady: () => Promise<void>;
}

export function createRootsGate(timeoutMs: number = 10000): RootsGate {
let resolve: () => void;
const promise = new Promise<void>((res) => {
resolve = res;
});

function waitForReady(): Promise<void> {
return Promise.race([
promise,
new Promise<void>((_, reject) =>
setTimeout(
() => reject(new Error(`Roots initialization timed out after ${timeoutMs}ms`)),
timeoutMs
)
),
]);
}

return {
promise,
resolve: resolve!,
waitForReady,
};
}
Loading