UNPKG

@modelcontextprotocol/server-filesystem

Version:
918 lines (917 loc) • 46.5 kB
// 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; }