@modelcontextprotocol/server-filesystem
Version:
MCP server for filesystem access
918 lines (917 loc) • 46.5 kB
JavaScript
// The filesystem server, built per call by createServer(). index.ts is the
// stdio entry point that resolves the allowed directories from argv and hands
// them here. Building the server in a factory, rather than at module load,
// lets the tests drive a real Client against it in-process over an in-memory
// transport (#4854), and holding the allow-list in this closure rather than in
// a module global lets two instances coexist in one process.
import { McpServer } from "@modelcontextprotocol/server";
import fs from "fs/promises";
import path from "path";
import { pathToFileURL } from "url";
import { z } from "zod";
import { getValidRootDirectories } from "./roots-utils.js";
import { confineRootsToDirectories, describeAllowedDirectories, } from "./allowed-directories.js";
import { formatSize, validatePath, getFileStats, readFileContent, writeFileContent, appendFileContent, createDirectory, moveFile, copyFile, searchFilesWithValidation, isExcludedPath, applyFileEdits, tailFile, headFile, readdirWithTimeout, createEntryBudget, } from "./lib.js";
import { registerFileResources } from "./file-resources.js";
import { describeReadOnlyDirectories } from "./read-only-directories.js";
import { SERVER_VERSION } from "./version.js";
// The JSON Schema dialect every advertised tool schema declares (#4841). The
// SDK 1.x tools/list handler rendered zod schemas with zod's draft-07 target and
// stamped "$schema": draft-07 on each one, which validators that accept only
// 2020-12 (the dialect MCP assumes for tool schemas) reject outright. Setting
// $schema in the root object's zod metadata overrode that stamp. SDK v2 stamps
// 2020-12 itself, so the label is now the SDK's default as well. The schemas
// below use nothing whose draft-07 and 2020-12 renderings differ (no tuples),
// so the label is the only change; server-tools.test.ts fails if a draft-07-only
// keyword ever appears under it.
const JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema";
/** A tool's input or output shape as an object schema declaring 2020-12. */
function jsonSchema2020(shape) {
return z.object(shape).meta({ $schema: JSON_SCHEMA_DIALECT });
}
// How every path argument is resolved, appended to its description: a model
// that is told only "the file" guesses at relative paths and at the sandbox.
const PATH_RULE = "Absolute, or relative to an allowed directory; it must resolve inside the allowed directories.";
/** A path argument, described as `what` followed by how it is resolved. */
function pathArg(what) {
return z.string().describe(`${what}. ${PATH_RULE}`);
}
// Schema definitions
const ReadTextFileArgsSchema = z.object({
path: pathArg("The file to read as text"),
tail: z
.number()
.int()
.min(0)
.optional()
.describe("If provided, returns only the last N lines of the file"),
head: z
.number()
.int()
.min(0)
.optional()
.describe("If provided, returns only the first N lines of the file"),
});
const ReadMediaFileArgsSchema = z.object({
path: pathArg("The image, audio or other file to read as base64"),
});
const ReadMultipleFilesArgsSchema = z.object({
paths: z
.array(z.string())
.min(1)
.describe("Array of file paths to read. Each path must be a string pointing to a valid file within allowed directories."),
});
const WriteFileArgsSchema = z.object({
path: pathArg("The file to create, or to overwrite if it exists; its parent directory must exist"),
content: z
.string()
.describe("The complete text the file will contain, replacing any it had"),
});
const AppendFileArgsSchema = z.object({
path: pathArg("The file to append to; it is created if it does not exist"),
content: z
.string()
.describe("Text to append, exactly as given. No newline is added: end it with \\n if the next append should start on a new line."),
});
const EditOperation = z.object({
oldText: z.string().describe("Text to search for - must match exactly"),
newText: z.string().describe("Text to replace with"),
});
const EditFileArgsSchema = z.object({
path: pathArg("The text file to edit"),
edits: z
.array(EditOperation)
.describe("The replacements to make, applied in order, each to the result of the ones before it. " +
"If any one has no match, none is written."),
dryRun: z
.boolean()
.default(false)
.describe("Preview changes using git-style diff format"),
});
const CreateDirectoryArgsSchema = z.object({
path: pathArg("The directory to create, with any missing parents; an existing directory is left as is"),
});
const ListDirectoryArgsSchema = z.object({
path: pathArg("The directory to list"),
});
const ListDirectoryWithSizesArgsSchema = z.object({
path: pathArg("The directory to list"),
sortBy: z
.enum(["name", "size"])
.optional()
.default("name")
.describe("Sort entries by name or size"),
});
const DirectoryTreeArgsSchema = z.object({
path: pathArg("The directory at the root of the tree"),
excludePatterns: z
.array(z.string())
.optional()
.default([])
.describe("Patterns for entries to leave out, with their contents, matched against each entry's path relative to path. " +
"A pattern with no '*' matches that name at any depth ('node_modules' leaves out every node_modules directory); " +
"a pattern with '*' is a glob matched as written, and one ending in '/**' also leaves out the directory it names."),
depth: z
.number()
.int()
.positive()
.optional()
.describe("How many levels to descend: 1 lists only the entries directly in path. " +
"Directories at the last level have no children array. Omit for the whole tree."),
});
const MoveFileArgsSchema = z.object({
source: pathArg("The file or directory to move or rename"),
destination: pathArg("Its new path, including the name; nothing may exist there yet"),
});
const CopyFileArgsSchema = z.object({
source: z.string().describe("The file to copy"),
destination: z
.string()
.describe("Where to create the copy; nothing may exist there yet"),
});
const SearchFilesArgsSchema = z.object({
path: pathArg("The directory to search under"),
pattern: z
.string()
.describe("Glob matched against each entry's path relative to path, never its content: " +
"'*.ts' matches directly under path, '**/*.ts' at any depth."),
excludePatterns: z
.array(z.string())
.optional()
.default([])
.describe("Patterns for entries to leave out, with their contents, matched against each entry's path relative to path. " +
"A pattern with no '*' matches that name at any depth ('node_modules' leaves out every node_modules directory); " +
"a pattern with '*' is a glob matched as written, and one ending in '/**' also leaves out the directory it names."),
});
const GetFileInfoArgsSchema = z.object({
path: pathArg("The file or directory to describe"),
});
/**
* Why the server cannot operate when it has no allowed directories from either
* the command line or the client's Roots (#4992).
*/
export const NO_ALLOWED_DIRECTORIES_ERROR = "Server cannot operate: No allowed directories available. Server was started without command-line directories and client does not support MCP roots protocol. Please either: 1) Start server with directory arguments, or 2) Use a client that supports MCP roots protocol and provides valid root directories.";
/**
* Why the server cannot operate on a 2026-07-28 connection when it was started
* with no allowed directories. That revision has no initialize handshake and
* no server-to-client roots/list, so the client's Roots cannot supply them
* (Roots is deprecated as of 2026-07-28); only the command line can (#4852).
*/
export const NO_ALLOWED_DIRECTORIES_MODERN_ERROR = "Server cannot operate: No allowed directories available. Server was started without command-line directories, and on protocol version 2026-07-28 the client cannot provide them through MCP Roots. Restart the server with the directories it may access as command-line arguments.";
/**
* Build a filesystem server that may touch only `initialAllowedDirectories`
* (already resolved and normalized by the caller), until the client's Roots
* replace them on a 2025-era connection.
*/
export function createServer(initialAllowedDirectories, options = {}) {
let allowedDirectories = [...initialAllowedDirectories];
const readOnlyDirectories = [...(options.readOnlyDirectories ?? [])];
// True once the client's roots have replaced the command-line directories.
// From then on a roots update replaces them even with none, so a client that
// withdraws its roots withdraws the server's access too (#5094). Until then an
// update with no valid roots keeps the command-line directories.
let rootsInForce = false;
// Each roots refresh (the initial load and every roots/list_changed) takes
// the next generation before it asks for roots. The SDK runs notification
// handlers without waiting for earlier ones, so answers can come back out of
// order; one is applied only if no newer refresh has started since, so a
// stale answer cannot undo a newer update, such as a revocation (#5097).
let rootsGeneration = 0;
const server = new McpServer({
name: "secure-filesystem-server",
version: SERVER_VERSION,
});
// The barrier every tool call waits on, so a call that arrives while roots are
// being fetched is checked against the roots that come back rather than the
// ones being replaced: the command-line directories while the initial
// roots/list is outstanding (#3204), or a root the client has just withdrawn
// while a roots/list_changed refresh is pending (#5101). Each refresh (the
// initial load included) installs a new barrier before it sends roots/list,
// and settles it when it finishes, successfully or not. Installing one also
// settles the barrier it replaces, so calls waiting on an older refresh wake
// at once and move on to the newer one instead of waiting for the older
// one's answer (#5097, #5101).
let barrier = { settled: Promise.resolve(), settle: () => { } };
function installBarrier() {
let settle;
const settled = new Promise((resolve) => (settle = resolve));
const replaced = barrier;
barrier = { settled, settle };
replaced.settle();
return settle;
}
const registerTool = server.registerTool.bind(server);
server.registerTool = ((name, config, handler) => registerTool(name, config, (async (...args) => {
let awaited;
do {
awaited = barrier;
await awaited.settled;
} while (awaited !== barrier);
// A 2025-era session with no directories closes at initialize (#4992).
// A 2026-07-28 one has no initialize to close at, and its client cannot
// add directories, so every call says why it cannot run.
if (options.era === "modern" && allowedDirectories.length === 0) {
return {
content: [
{ type: "text", text: NO_ALLOWED_DIRECTORIES_MODERN_ERROR },
],
isError: true,
};
}
return handler(...args);
})));
// Read-only mode (#632, #1160) withholds every tool that does not declare
// itself read-only. Keying on the annotation rather than on a list of write
// tools fails closed: a new tool is withheld unless its registration says
// `readOnlyHint: true`, the hint the repository already requires to be set
// truthfully, so a new write tool needs no change here. A withheld tool is
// registered and then removed, before any client connects, so it is absent
// from tools/list and a call to it is answered as an unknown tool.
if (options.readOnly) {
const registerReadWrite = server.registerTool.bind(server);
server.registerTool = ((name, config, handler) => {
const tool = registerReadWrite(name, config, handler);
if (config.annotations?.readOnlyHint !== true)
tool.remove();
return tool;
});
}
// Tool registrations
// read_file (deprecated) and read_text_file
const readTextFileHandler = async (args) => {
const validPath = await validatePath(args.path, allowedDirectories, "read");
// Compared against undefined, not tested for truthiness: a count of 0
// asks for zero lines, not for the whole file (#5184).
if (args.head !== undefined && args.tail !== undefined) {
throw new Error("Cannot specify both head and tail parameters simultaneously");
}
let content;
if (args.tail !== undefined) {
content = await tailFile(validPath, args.tail);
}
else if (args.head !== undefined) {
content = await headFile(validPath, args.head);
}
else {
content = await readFileContent(validPath);
}
return {
content: [{ type: "text", text: content }],
structuredContent: { content },
};
};
server.registerTool("read_file", {
title: "Read File (Deprecated)",
description: "Read the complete contents of a file as text. DEPRECATED: Use read_text_file instead.",
inputSchema: jsonSchema2020(ReadTextFileArgsSchema.shape),
outputSchema: jsonSchema2020({ content: z.string() }),
annotations: {
readOnlyHint: true,
idempotentHint: true,
destructiveHint: false,
openWorldHint: false,
},
}, readTextFileHandler);
server.registerTool("read_text_file", {
title: "Read Text File",
description: "Read the complete contents of a file from the file system as text. " +
"Handles various text encodings and provides detailed error messages " +
"if the file cannot be read. Use this tool when you need to examine " +
"the contents of a single file. Use the 'head' parameter to read only " +
"the first N lines of a file, or the 'tail' parameter to read only " +
"the last N lines of a file. Operates on the file as text regardless of extension. " +
"Only works within allowed directories.",
inputSchema: jsonSchema2020(ReadTextFileArgsSchema.shape),
outputSchema: jsonSchema2020({ content: z.string() }),
annotations: {
readOnlyHint: true,
idempotentHint: true,
destructiveHint: false,
openWorldHint: false,
},
}, readTextFileHandler);
server.registerTool("read_media_file", {
title: "Read Media File",
description: "Read a file and return it as a base64-encoded content block with its MIME type. " +
"Image and audio files are returned as image/audio content; any other file type is " +
"returned as an embedded resource. Only works within allowed directories.",
inputSchema: jsonSchema2020(ReadMediaFileArgsSchema.shape),
outputSchema: jsonSchema2020({
content: z.array(z.union([
z.object({
type: z.enum(["image", "audio"]),
data: z.string(),
mimeType: z.string(),
}),
z.object({
type: z.literal("resource"),
resource: z.object({
uri: z.string(),
// Optional, matching the SDK's BlobResourceContents shape (the handler always sets it).
mimeType: z.string().optional(),
blob: z.string(),
}),
}),
])),
}),
annotations: {
readOnlyHint: true,
idempotentHint: true,
destructiveHint: false,
openWorldHint: false,
},
}, async (args) => {
const validPath = await validatePath(args.path, allowedDirectories, "read");
const extension = path.extname(validPath).toLowerCase();
const mimeTypes = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".svg": "image/svg+xml",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".ogg": "audio/ogg",
".flac": "audio/flac",
};
const mimeType = mimeTypes[extension] || "application/octet-stream";
// Read through the anchored descriptor, as every read is (lib.ts).
const data = await readFileContent(validPath, "base64");
// Map the MIME type to a valid MCP content block. The spec only allows
// text, image, audio, resource_link, and resource — so non-image/audio
// binaries are returned as an embedded resource (NOT type:"blob", which the
// SDK content-block union rejects on schema validation).
const contentItem = mimeType.startsWith("image/")
? { type: "image", data, mimeType }
: mimeType.startsWith("audio/")
? { type: "audio", data, mimeType }
: {
type: "resource",
resource: {
uri: pathToFileURL(validPath).href,
mimeType,
blob: data,
},
};
return {
content: [contentItem],
structuredContent: { content: [contentItem] },
};
});
server.registerTool("read_multiple_files", {
title: "Read Multiple Files",
description: "Read the contents of multiple files simultaneously. This is more " +
"efficient than reading files one by one when you need to analyze " +
"or compare multiple files. Each file's content is returned with its " +
"path as a reference. Failed reads for individual files won't stop " +
"the entire operation. Only works within allowed directories.",
inputSchema: jsonSchema2020(ReadMultipleFilesArgsSchema.shape),
outputSchema: jsonSchema2020({ content: z.string() }),
annotations: {
readOnlyHint: true,
idempotentHint: true,
destructiveHint: false,
openWorldHint: false,
},
}, async (args) => {
const results = await Promise.all(args.paths.map(async (filePath) => {
try {
const validPath = await validatePath(filePath, allowedDirectories, "read");
const content = await readFileContent(validPath);
return `${filePath}:\n${content}\n`;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return `${filePath}: Error - ${errorMessage}`;
}
}));
const text = results.join("\n---\n");
return {
content: [{ type: "text", text }],
structuredContent: { content: text },
};
});
server.registerTool("write_file", {
title: "Write File",
description: "Create a new file or completely overwrite an existing file with new content. " +
"Use with caution as it will overwrite existing files without warning. " +
"Handles text content with proper encoding. Only works within allowed directories.",
inputSchema: jsonSchema2020(WriteFileArgsSchema.shape),
outputSchema: jsonSchema2020({ content: z.string() }),
annotations: {
readOnlyHint: false,
idempotentHint: true,
destructiveHint: true,
openWorldHint: false,
},
}, async (args) => {
const validPath = await validatePath(args.path, allowedDirectories, "write", readOnlyDirectories);
await writeFileContent(validPath, args.content);
const text = `Successfully wrote to ${args.path}`;
return {
content: [{ type: "text", text }],
structuredContent: { content: text },
};
});
server.registerTool("append_file", {
title: "Append File",
description: "Append text to the end of a file, creating the file if it does not exist. " +
"The existing content is never changed or replaced. The text is appended exactly " +
"as given, with no newline added before or after it, so end it with a newline " +
"if the next append should start on a new line. Only works within allowed directories.",
inputSchema: jsonSchema2020(AppendFileArgsSchema.shape),
outputSchema: jsonSchema2020({ content: z.string() }),
annotations: {
readOnlyHint: false,
idempotentHint: false,
destructiveHint: false,
openWorldHint: false,
},
}, async (args) => {
const validPath = await validatePath(args.path, allowedDirectories, "write", readOnlyDirectories);
await appendFileContent(validPath, args.content);
const text = `Successfully appended to ${args.path}`;
return {
content: [{ type: "text", text }],
structuredContent: { content: text },
};
});
server.registerTool("edit_file", {
title: "Edit File",
description: "Make line-based edits to a text file. Edits apply in order, each to the " +
"result of the ones before it, and each replaces the first match of its " +
"oldText with newText. oldText must match the file's text exactly, except " +
"that line endings (LF or CRLF) never matter and, when there is no exact " +
"match, a run of whole lines matches if each line is equal once leading and " +
"trailing whitespace is ignored (newText then takes the file's indentation). " +
"Whitespace inside a line must match. If any edit has no match, nothing is " +
"written and the error shows the closest lines of the file, numbered, to " +
"copy oldText from. Returns a git-style diff showing the changes made. " +
"Only works within allowed directories.",
inputSchema: jsonSchema2020(EditFileArgsSchema.shape),
outputSchema: jsonSchema2020({ content: z.string() }),
annotations: {
readOnlyHint: false,
idempotentHint: false,
destructiveHint: true,
openWorldHint: false,
},
}, async (args) => {
const validPath = await validatePath(args.path, allowedDirectories, "write", readOnlyDirectories);
const result = await applyFileEdits(validPath, args.edits, args.dryRun);
return {
content: [{ type: "text", text: result }],
structuredContent: { content: result },
};
});
server.registerTool("create_directory", {
title: "Create Directory",
description: "Create a new directory or ensure a directory exists. Can create multiple " +
"nested directories in one operation. If the directory already exists, " +
"this operation will succeed silently. Perfect for setting up directory " +
"structures for projects or ensuring required paths exist. Only works within allowed directories.",
inputSchema: jsonSchema2020(CreateDirectoryArgsSchema.shape),
outputSchema: jsonSchema2020({ content: z.string() }),
annotations: {
readOnlyHint: false,
idempotentHint: true,
destructiveHint: false,
openWorldHint: false,
},
}, async (args) => {
const validPath = await validatePath(args.path, allowedDirectories, "write", readOnlyDirectories);
await createDirectory(validPath);
const text = `Successfully created directory ${args.path}`;
return {
content: [{ type: "text", text }],
structuredContent: { content: text },
};
});
server.registerTool("list_directory", {
title: "List Directory",
description: "List the files and directories directly inside a path, each marked [FILE] or [DIR]. " +
"Use it to see what a directory contains; when you also need each entry's size or " +
"want entries sorted by size, use list_directory_with_sizes instead, and for a " +
"recursive view use directory_tree. Returns one line per entry. Only works within " +
"allowed directories.",
inputSchema: jsonSchema2020(ListDirectoryArgsSchema.shape),
outputSchema: jsonSchema2020({ content: z.string() }),
annotations: {
readOnlyHint: true,
idempotentHint: true,
destructiveHint: false,
openWorldHint: false,
},
}, async (args) => {
const validPath = await validatePath(args.path, allowedDirectories, "read");
const entries = await fs.readdir(validPath, { withFileTypes: true });
const formatted = entries
.map((entry) => `${entry.isDirectory() ? "[DIR]" : "[FILE]"} ${entry.name}`)
.join("\n");
return {
content: [{ type: "text", text: formatted }],
structuredContent: { content: formatted },
};
});
server.registerTool("list_directory_with_sizes", {
title: "List Directory with Sizes",
description: "List the files and directories directly inside a path with each file's size, " +
"marked [FILE] or [DIR], sorted by name or by size, largest first (sortBy). Use it " +
"when sizes matter, such as finding the largest files; for names only, use " +
"list_directory, which returns less text. Returns one line per entry, followed by " +
"the totals. Only works within allowed directories.",
inputSchema: jsonSchema2020(ListDirectoryWithSizesArgsSchema.shape),
outputSchema: jsonSchema2020({ content: z.string() }),
annotations: {
readOnlyHint: true,
idempotentHint: true,
destructiveHint: false,
openWorldHint: false,
},
}, async (args) => {
const validPath = await validatePath(args.path, allowedDirectories, "read");
const entries = await fs.readdir(validPath, { withFileTypes: true });
const detailedEntries = await Promise.all(entries.map(async (entry) => {
const entryPath = path.join(validPath, entry.name);
try {
// Validate each entry, so a symlink whose target is outside the
// allowed directories is listed with no size rather than the
// target's; the size is then read from the resolved entry,
// anchored to its directory (lib.ts).
const validEntryPath = await validatePath(entryPath, allowedDirectories, "read");
const stats = await getFileStats(validEntryPath);
return {
name: entry.name,
isDirectory: entry.isDirectory(),
size: stats.size,
};
}
catch {
// The entry could not be stat'd (unreadable, a dangling symlink,
// removed since readdir, or outside the allowed directories).
// Its size is unknown, not 0: inventing one would list it as an
// empty file and count it in the total (#5190).
return {
name: entry.name,
isDirectory: entry.isDirectory(),
size: undefined,
};
}
}));
// Sort entries based on sortBy parameter
const sortedEntries = [...detailedEntries].sort((a, b) => {
if (args.sortBy === "size") {
// Descending by size; an entry of unknown size sorts last
return (b.size ?? -1) - (a.size ?? -1);
}
// Default sort by name
return a.name.localeCompare(b.name);
});
// Format the output
const formattedEntries = sortedEntries.map((entry) => `${entry.isDirectory ? "[DIR]" : "[FILE]"} ${entry.name.padEnd(30)} ${entry.isDirectory
? ""
: (entry.size === undefined
? "unknown"
: formatSize(entry.size)).padStart(10)}`);
// Add summary
const totalFiles = detailedEntries.filter((e) => !e.isDirectory).length;
const totalDirs = detailedEntries.filter((e) => e.isDirectory).length;
const totalSize = detailedEntries.reduce((sum, entry) => sum + (entry.isDirectory ? 0 : (entry.size ?? 0)), 0);
const unknownFiles = detailedEntries.filter((e) => !e.isDirectory && e.size === undefined).length;
const summary = [
"",
`Total: ${totalFiles} files, ${totalDirs} directories`,
`Combined size: ${formatSize(totalSize)}` +
(unknownFiles > 0
? ` (excludes ${unknownFiles} ${unknownFiles === 1 ? "file" : "files"} of unknown size)`
: ""),
];
const text = [...formattedEntries, ...summary].join("\n");
const contentBlock = { type: "text", text };
return {
content: [contentBlock],
structuredContent: { content: text },
};
});
server.registerTool("directory_tree", {
title: "Directory Tree",
description: "Get a recursive tree view of files and directories as a JSON structure. " +
"Each entry includes 'name', 'type' (file/directory), and 'children' for directories. " +
"Files have no children array, while directories always have a children array (which may be empty), " +
"except at the last level when 'depth' limits the tree: those directories are not read and have no children array. " +
"The output is formatted with 2-space indentation for readability. Only works within allowed directories.",
inputSchema: jsonSchema2020(DirectoryTreeArgsSchema.shape),
outputSchema: jsonSchema2020({ content: z.string() }),
annotations: {
readOnlyHint: true,
idempotentHint: true,
destructiveHint: false,
openWorldHint: false,
},
}, async (args) => {
const rootPath = args.path;
const visit = createEntryBudget("directory_tree", rootPath);
// `level` is the depth of currentPath's entries: 1 for those directly
// in the requested path. A directory at the depth limit is listed but
// not read, so its subtree costs no readdir and no entry budget (#1077).
async function buildTree(currentPath, excludePatterns = [], level = 1) {
const validPath = await validatePath(currentPath, allowedDirectories, "read");
const entries = await readdirWithTimeout(validPath);
const result = [];
for (const entry of entries) {
const relativePath = path.relative(rootPath, path.join(currentPath, entry.name));
if (isExcludedPath(relativePath, excludePatterns, entry.isDirectory()))
continue;
// Counted after the exclude check, so excluded subtrees cost
// nothing; the cap and the readdir timeout keep one call from
// walking an unbounded or stalled tree (#4162).
visit();
const entryData = {
name: entry.name,
type: entry.isDirectory() ? "directory" : "file",
};
if (entry.isDirectory() &&
(args.depth === undefined || level < args.depth)) {
const subPath = path.join(currentPath, entry.name);
entryData.children = await buildTree(subPath, excludePatterns, level + 1);
}
result.push(entryData);
}
return result;
}
const treeData = await buildTree(rootPath, args.excludePatterns);
const text = JSON.stringify(treeData, null, 2);
const contentBlock = { type: "text", text };
return {
content: [contentBlock],
structuredContent: { content: text },
};
});
server.registerTool("move_file", {
title: "Move File",
description: "Move or rename files and directories. Can move files between directories " +
"and rename them in a single operation. If the destination exists, the " +
"operation will fail. Works across different directories and can be used " +
"for simple renaming within the same directory. Both source and destination must be within allowed directories.",
inputSchema: jsonSchema2020(MoveFileArgsSchema.shape),
outputSchema: jsonSchema2020({ content: z.string() }),
annotations: {
readOnlyHint: false,
idempotentHint: false,
destructiveHint: true,
openWorldHint: false,
},
}, async (args) => {
const validSourcePath = await validatePath(args.source, allowedDirectories, "move", readOnlyDirectories);
const validDestPath = await validatePath(args.destination, allowedDirectories, "move", readOnlyDirectories);
await moveFile(validSourcePath, validDestPath);
const text = `Successfully moved ${args.source} to ${args.destination}`;
const contentBlock = { type: "text", text };
return {
content: [contentBlock],
structuredContent: { content: text },
};
});
server.registerTool("copy_file", {
title: "Copy File",
description: "Copy a file to a new location, without reading its content into the conversation. " +
"Copies one regular file, keeping its permissions and modification time; directories " +
"are not copied. If the destination exists, the operation will fail: it never " +
"overwrites. Both source and destination must be within allowed directories.",
inputSchema: jsonSchema2020(CopyFileArgsSchema.shape),
outputSchema: jsonSchema2020({ content: z.string() }),
annotations: {
readOnlyHint: false,
idempotentHint: true,
destructiveHint: false,
openWorldHint: false,
},
}, async (args) => {
const validSourcePath = await validatePath(args.source, allowedDirectories, "read");
const validDestPath = await validatePath(args.destination, allowedDirectories, "write", readOnlyDirectories);
await copyFile(validSourcePath, validDestPath);
const text = `Successfully copied ${args.source} to ${args.destination}`;
return {
content: [{ type: "text", text }],
structuredContent: { content: text },
};
});
server.registerTool("search_files", {
title: "Search Files",
description: "Find files and directories by NAME, not by content: this tool never reads or searches inside files. " +
"It walks the tree under 'path' and matches a glob pattern against each entry's path relative to 'path' (case-sensitive). " +
"Use '*.ext' to match entries directly under 'path', and '**/*.ext' or '**/name' to match at any depth. " +
"Returns full paths to all matching items. Great for finding files when you don't know their exact location. " +
"Only searches within allowed directories.",
inputSchema: jsonSchema2020(SearchFilesArgsSchema.shape),
outputSchema: jsonSchema2020({ content: z.string() }),
annotations: {
readOnlyHint: true,
idempotentHint: true,
destructiveHint: false,
openWorldHint: false,
},
}, async (args) => {
const validPath = await validatePath(args.path, allowedDirectories, "read");
// A subdirectory that cannot be read is skipped, and named after the
// matches so the caller knows they are partial (#5189).
const unreadable = [];
const results = await searchFilesWithValidation(validPath, args.pattern, allowedDirectories, {
excludePatterns: args.excludePatterns,
onUnreadable: (directory, error) => {
const code = error.code;
unreadable.push(code ? `${directory} (${code})` : directory);
},
});
let text = results.length > 0 ? results.join("\n") : "No matches found";
if (unreadable.length > 0) {
text +=
`\n\nThe search is incomplete: ${unreadable.length} ` +
`${unreadable.length === 1 ? "directory" : "directories"} could not be read, ` +
"and any matches inside are missing:\n" +
unreadable.join("\n");
}
return {
content: [{ type: "text", text }],
structuredContent: { content: text },
};
});
server.registerTool("get_file_info", {
title: "Get File Info",
description: "Retrieve detailed metadata about a file or directory. Returns comprehensive " +
"information including size, creation time, last modified time, permissions, " +
"type and hard-link count. This tool is perfect for understanding file characteristics " +
"without reading the actual content. Only works within allowed directories.",
inputSchema: jsonSchema2020(GetFileInfoArgsSchema.shape),
outputSchema: jsonSchema2020({ content: z.string() }),
annotations: {
readOnlyHint: true,
idempotentHint: true,
destructiveHint: false,
openWorldHint: false,
},
}, async (args) => {
const validPath = await validatePath(args.path, allowedDirectories, "read");
const info = await getFileStats(validPath);
const text = Object.entries(info)
.map(([key, value]) => `${key}: ${value}`)
.join("\n");
return {
content: [{ type: "text", text }],
structuredContent: { content: text },
};
});
server.registerTool("list_allowed_directories", {
title: "List Allowed Directories",
description: "Returns the list of directories that this server is allowed to access. " +
"Subdirectories within these allowed directories are also accessible. " +
"Use this to understand which directories and their nested paths are available " +
"before trying to access files.",
inputSchema: jsonSchema2020({}),
outputSchema: jsonSchema2020({ content: z.string() }),
annotations: {
readOnlyHint: true,
idempotentHint: true,
destructiveHint: false,
openWorldHint: false,
},
}, async () => {
// The read-only directories follow as a section of their own (#5348),
// only when there are any, so a client can tell before it writes.
const text = `Allowed directories:\n${allowedDirectories.join("\n")}` +
(readOnlyDirectories.length > 0
? `\n\n${describeReadOnlyDirectories(readOnlyDirectories)}`
: "");
return {
content: [{ type: "text", text }],
structuredContent: { content: text },
};
});
// Files as resources (#297): reads see the same allow-list, roots barrier
// and 2026-07-28 no-directories error as the tools above.
registerFileResources(server, {
allowedDirectories: () => allowedDirectories,
rootsSettled: () => barrier.settled,
unavailableReason: () => options.era === "modern" && allowedDirectories.length === 0
? NO_ALLOWED_DIRECTORIES_MODERN_ERROR
: undefined,
});
// Updates allowed directories based on MCP client roots, unless a newer
// refresh started after this one's `generation` was taken (#5097).
async function updateAllowedDirectoriesFromRoots(requestedRoots, generation) {
const validRootDirs = await getValidRootDirectories(requestedRoots);
if (generation !== rootsGeneration) {
console.error("Discarded a stale roots/list answer: a newer roots update has started");
return;
}
const validatedRootDirs = confineRoots(validRootDirs);
if (validatedRootDirs.length > 0) {
allowedDirectories = [...validatedRootDirs];
rootsInForce = true;
console.error(`Updated allowed directories from MCP roots: ${validatedRootDirs.length} valid directories`);
}
else if (rootsInForce) {
allowedDirectories = [];
console.error("No valid root directories provided by client; access revoked until it exposes a root again");
}
else {
console.error("No valid root directories provided by client");
}
logAllowedDirectories();
}
// Log the allowed directories now in force and where they came from (#4912).
function logAllowedDirectories() {
console.error(describeAllowedDirectories(allowedDirectories, rootsInForce
? {
from: "roots",
replacedCommandLine: initialAllowedDirectories.length > 0,
confined: confining,
}
: { from: "command-line" }));
}
// True when the client's Roots are kept inside the command-line directories
// (#3133). With none given there is nothing to confine them to.
const confining = options.confineRoots === true && initialAllowedDirectories.length > 0;
// The client's valid roots as the server will apply them: unchanged, or,
// with --confine-roots, cut down to their overlap with the command-line
// directories. A root outside all of them is dropped like an invalid one,
// so roots that are all outside leave the state as invalid roots would.
function confineRoots(validRootDirs) {
if (!confining)
return validRootDirs;
const { confined, ignored } = confineRootsToDirectories(validRootDirs, initialAllowedDirectories);
if (ignored.length > 0) {
console.error(`Ignored client roots outside the command-line directories (--confine-roots): ${ignored.join(", ")}`);
}
return confined;
}
// Handles dynamic roots updates during runtime, when client sends "roots/list_changed" notification, server fetches the updated roots and replaces all allowed directories with the new roots.
server.server.setNotificationHandler("notifications/roots/list_changed", async () => {
const generation = ++rootsGeneration;
const settle = installBarrier();
try {
// Request the updated roots list from the client
const response = await server.server.listRoots();
/* v8 ignore else -- the SDK validates the roots/list result against ListRootsResultSchema, which requires roots, so the implicit else cannot run */
if (response && "roots" in response) {
await updateAllowedDirectoriesFromRoots(response.roots, generation);
}
}
catch (error) {
console.error("Failed to request roots from client:", error instanceof Error ? error.message : String(error));
}
finally {
settle();
}
});
// Handles post-initialization setup, specifically checking for and fetching
// MCP roots. Only a 2025-era connection initializes, so this never runs on
// 2026-07-28, where Roots is unavailable (and deprecated). The initial load claims its generation and installs its barrier
// here, synchronously, before any roots/list_changed handled afterwards can
// claim a newer one.
server.server.oninitialized = () => {
const generation = ++rootsGeneration;
const settle = installBarrier();
// Not awaited: oninitialized is synchronous. Tool calls wait on the
// barrier, which settles when initialize() finishes; it never rejects.
void initialize(generation).finally(settle);
};
async function initialize(generation) {
const clientCapabilities = server.server.getClientCapabilities();
if (clientCapabilities?.roots) {
try {
const response = await server.server.listRoots();
/* v8 ignore else -- the SDK validates the roots/list result against ListRootsResultSchema, which requires roots, so the else cannot run */
if (response && "roots" in response) {
await updateAllowedDirectoriesFromRoots(response.roots, generation);
}
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));
logAllowedDirectories();
}
}
else {
if (allowedDirectories.length > 0) {
console.error("Client does not support MCP Roots, using allowed directories set from server args:", allowedDirectories);
logAllowedDirectories();
}
else {
// Nothing could ever be allowed in this session, so fail visibly
// rather than serve every call with "Access denied" (#4992): log the
// reason and close the connection. Throwing here would only reach the
// SDK's onerror, which the client never sees.
const error = new Error(NO_ALLOWED_DIRECTORIES_ERROR);
console.error(`Error: ${error.message}`);
await server.close();
options.onCannotOperate?.(error);
}
}
}
return server;
}