fix(filesystem): convert to modern TypeScript SDK APIs (#3016)

* fix(filesystem): convert to modern TypeScript SDK APIs

Convert the filesystem server to use the modern McpServer API instead
of the low-level Server API.

Key changes:
- Replace Server with McpServer from @modelcontextprotocol/sdk/server/mcp.js
- Convert all 13 tools to use registerTool() instead of manual request handlers
- Use Zod schemas directly in inputSchema/outputSchema
- Add structuredContent to all tool responses
- Fix type literals to use 'as const' assertions
- Update roots protocol handling to use server.server.* pattern
- Fix tsconfig to exclude vitest.config.ts

Tools converted:
- read_file (deprecated)
- read_text_file
- read_media_file
- read_multiple_files
- write_file
- edit_file
- create_directory
- list_directory
- list_directory_with_sizes
- directory_tree
- move_file
- search_files
- get_file_info
- list_allowed_directories

The modern API provides:
- Less boilerplate code
- Better type safety with Zod
- More declarative tool registration
- Cleaner, more maintainable code

* fix: use default import for minimatch

minimatch v10+ uses default export instead of named export

* fix(filesystem): use named import for minimatch

The minimatch module doesn't have a default export, so we need to use
the named import syntax instead.

Fixes TypeScript compilation error:
error TS2613: Module has no default export. Did you mean to use
'import { minimatch } from "minimatch"' instead?
This commit is contained in:
adam jones
2025-11-20 17:00:04 +00:00
committed by GitHub
parent 28a313206c
commit 4dc24cf349
2 changed files with 565 additions and 477 deletions

View File

@@ -1,11 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { import {
CallToolRequestSchema,
ListToolsRequestSchema,
ToolSchema,
RootsListChangedNotificationSchema, RootsListChangedNotificationSchema,
type Root, type Root,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
@@ -13,7 +10,6 @@ import fs from "fs/promises";
import { createReadStream } from "fs"; import { createReadStream } from "fs";
import path from "path"; import path from "path";
import { z } from "zod"; import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import { minimatch } from "minimatch"; import { minimatch } from "minimatch";
import { normalizePath, expandHome } from './path-utils.js'; import { normalizePath, expandHome } from './path-utils.js';
import { getValidRootDirectories } from './roots-utils.js'; import { getValidRootDirectories } from './roots-utils.js';
@@ -143,20 +139,12 @@ const GetFileInfoArgsSchema = z.object({
path: z.string(), path: z.string(),
}); });
const ToolInputSchema = ToolSchema.shape.inputSchema;
type ToolInput = z.infer<typeof ToolInputSchema>;
// Server setup // Server setup
const server = new Server( const server = new McpServer(
{ {
name: "secure-filesystem-server", name: "secure-filesystem-server",
version: "0.2.0", version: "0.2.0",
}, }
{
capabilities: {
tools: {},
},
},
); );
// Reads a file as a stream of buffers, concatenates them, and then encodes // Reads a file as a stream of buffers, concatenates them, and then encodes
@@ -177,17 +165,59 @@ async function readFileAsBase64Stream(filePath: string): Promise<string> {
}); });
} }
// Tool handlers // Tool registrations
server.setRequestHandler(ListToolsRequestSchema, async () => {
// read_file (deprecated) and read_text_file
const readTextFileHandler = async (args: z.infer<typeof ReadTextFileArgsSchema>) => {
const validPath = await validatePath(args.path);
if (args.head && args.tail) {
throw new Error("Cannot specify both head and tail parameters simultaneously");
}
if (args.tail) {
const tailContent = await tailFile(validPath, args.tail);
return { return {
tools: [ content: [{ type: "text" as const, text: tailContent }],
};
}
if (args.head) {
const headContent = await headFile(validPath, args.head);
return {
content: [{ type: "text" as const, text: headContent }],
};
}
const content = await readFileContent(validPath);
return {
content: [{ type: "text" as const, text: content }],
};
};
server.registerTool(
"read_file",
{ {
name: "read_file", title: "Read File (Deprecated)",
description: "Read the complete contents of a file as text. DEPRECATED: Use read_text_file instead.", description: "Read the complete contents of a file as text. DEPRECATED: Use read_text_file instead.",
inputSchema: zodToJsonSchema(ReadTextFileArgsSchema) as ToolInput, inputSchema: {
path: z.string(),
tail: z.number().optional().describe("If provided, returns only the last N lines of the file"),
head: z.number().optional().describe("If provided, returns only the first N lines of the file")
}, },
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
}
},
readTextFileHandler
);
server.registerTool(
"read_text_file",
{ {
name: "read_text_file", title: "Read Text File",
description: description:
"Read the complete contents of a file from the file system as text. " + "Read the complete contents of a file from the file system as text. " +
"Handles various text encodings and provides detailed error messages " + "Handles various text encodings and provides detailed error messages " +
@@ -196,167 +226,41 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
"the first N lines of a file, or the 'tail' 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. " + "the last N lines of a file. Operates on the file as text regardless of extension. " +
"Only works within allowed directories.", "Only works within allowed directories.",
inputSchema: zodToJsonSchema(ReadTextFileArgsSchema) as ToolInput, inputSchema: {
path: z.string(),
tail: z.number().optional().describe("If provided, returns only the last N lines of the file"),
head: z.number().optional().describe("If provided, returns only the first N lines of the file")
}, },
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
}
},
readTextFileHandler
);
server.registerTool(
"read_media_file",
{ {
name: "read_media_file", title: "Read Media File",
description: description:
"Read an image or audio file. Returns the base64 encoded data and MIME type. " + "Read an image or audio file. Returns the base64 encoded data and MIME type. " +
"Only works within allowed directories.", "Only works within allowed directories.",
inputSchema: zodToJsonSchema(ReadMediaFileArgsSchema) as ToolInput,
},
{
name: "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: zodToJsonSchema(ReadMultipleFilesArgsSchema) as ToolInput,
},
{
name: "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: zodToJsonSchema(WriteFileArgsSchema) as ToolInput,
},
{
name: "edit_file",
description:
"Make line-based edits to a text file. Each edit replaces exact line sequences " +
"with new content. Returns a git-style diff showing the changes made. " +
"Only works within allowed directories.",
inputSchema: zodToJsonSchema(EditFileArgsSchema) as ToolInput,
},
{
name: "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: zodToJsonSchema(CreateDirectoryArgsSchema) as ToolInput,
},
{
name: "list_directory",
description:
"Get a detailed listing of all files and directories in a specified path. " +
"Results clearly distinguish between files and directories with [FILE] and [DIR] " +
"prefixes. This tool is essential for understanding directory structure and " +
"finding specific files within a directory. Only works within allowed directories.",
inputSchema: zodToJsonSchema(ListDirectoryArgsSchema) as ToolInput,
},
{
name: "list_directory_with_sizes",
description:
"Get a detailed listing of all files and directories in a specified path, including sizes. " +
"Results clearly distinguish between files and directories with [FILE] and [DIR] " +
"prefixes. This tool is useful for understanding directory structure and " +
"finding specific files within a directory. Only works within allowed directories.",
inputSchema: zodToJsonSchema(ListDirectoryWithSizesArgsSchema) as ToolInput,
},
{
name: "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). " +
"The output is formatted with 2-space indentation for readability. Only works within allowed directories.",
inputSchema: zodToJsonSchema(DirectoryTreeArgsSchema) as ToolInput,
},
{
name: "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: zodToJsonSchema(MoveFileArgsSchema) as ToolInput,
},
{
name: "search_files",
description:
"Recursively search for files and directories matching a pattern. " +
"The patterns should be glob-style patterns that match paths relative to the working directory. " +
"Use pattern like '*.ext' to match files in current directory, and '**/*.ext' to match files in all subdirectories. " +
"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: zodToJsonSchema(SearchFilesArgsSchema) as ToolInput,
},
{
name: "get_file_info",
description:
"Retrieve detailed metadata about a file or directory. Returns comprehensive " +
"information including size, creation time, last modified time, permissions, " +
"and type. This tool is perfect for understanding file characteristics " +
"without reading the actual content. Only works within allowed directories.",
inputSchema: zodToJsonSchema(GetFileInfoArgsSchema) as ToolInput,
},
{
name: "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: { inputSchema: {
type: "object", path: z.string()
properties: {},
required: [],
}, },
outputSchema: {
content: z.array(z.object({
type: z.enum(["image", "audio"]),
data: z.string(),
mimeType: z.string()
}))
}
}, },
], async (args: z.infer<typeof ReadMediaFileArgsSchema>) => {
}; const validPath = await validatePath(args.path);
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params;
switch (name) {
case "read_file":
case "read_text_file": {
const parsed = ReadTextFileArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for read_text_file: ${parsed.error}`);
}
const validPath = await validatePath(parsed.data.path);
if (parsed.data.head && parsed.data.tail) {
throw new Error("Cannot specify both head and tail parameters simultaneously");
}
if (parsed.data.tail) {
// Use memory-efficient tail implementation for large files
const tailContent = await tailFile(validPath, parsed.data.tail);
return {
content: [{ type: "text", text: tailContent }],
};
}
if (parsed.data.head) {
// Use memory-efficient head implementation for large files
const headContent = await headFile(validPath, parsed.data.head);
return {
content: [{ type: "text", text: headContent }],
};
}
const content = await readFileContent(validPath);
return {
content: [{ type: "text", text: content }],
};
}
case "read_media_file": {
const parsed = ReadMediaFileArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for read_media_file: ${parsed.error}`);
}
const validPath = await validatePath(parsed.data.path);
const extension = path.extname(validPath).toLowerCase(); const extension = path.extname(validPath).toLowerCase();
const mimeTypes: Record<string, string> = { const mimeTypes: Record<string, string> = {
".png": "image/png", ".png": "image/png",
@@ -373,23 +277,46 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
}; };
const mimeType = mimeTypes[extension] || "application/octet-stream"; const mimeType = mimeTypes[extension] || "application/octet-stream";
const data = await readFileAsBase64Stream(validPath); const data = await readFileAsBase64Stream(validPath);
const type = mimeType.startsWith("image/")
? "image" if (mimeType.startsWith("audio/")) {
: mimeType.startsWith("audio/")
? "audio"
: "blob";
return { return {
content: [{ type, data, mimeType }], content: [{ type: "audio" as const, data, mimeType }],
};
} else {
// For all other media types including images and unknown types, return as image
// (MCP ImageContent can handle any base64-encoded binary data with appropriate mimeType)
return {
content: [{ type: "image" as const, data, mimeType }],
}; };
} }
case "read_multiple_files": {
const parsed = ReadMultipleFilesArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for read_multiple_files: ${parsed.error}`);
} }
);
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: {
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.")
},
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
}
},
async (args: z.infer<typeof ReadMultipleFilesArgsSchema>) => {
const results = await Promise.all( const results = await Promise.all(
parsed.data.paths.map(async (filePath: string) => { args.paths.map(async (filePath: string) => {
try { try {
const validPath = await validatePath(filePath); const validPath = await validatePath(filePath);
const content = await readFileContent(validPath); const content = await readFileContent(validPath);
@@ -401,67 +328,152 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
}), }),
); );
return { return {
content: [{ type: "text", text: results.join("\n---\n") }], content: [{ type: "text" as const, text: results.join("\n---\n") }],
}; };
} }
);
case "write_file": { server.registerTool(
const parsed = WriteFileArgsSchema.safeParse(args); "write_file",
if (!parsed.success) { {
throw new Error(`Invalid arguments for write_file: ${parsed.error}`); 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: {
path: z.string(),
content: z.string()
},
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
} }
const validPath = await validatePath(parsed.data.path); },
await writeFileContent(validPath, parsed.data.content); async (args: z.infer<typeof WriteFileArgsSchema>) => {
const validPath = await validatePath(args.path);
await writeFileContent(validPath, args.content);
return { return {
content: [{ type: "text", text: `Successfully wrote to ${parsed.data.path}` }], content: [{ type: "text" as const, text: `Successfully wrote to ${args.path}` }],
}; };
} }
);
case "edit_file": { server.registerTool(
const parsed = EditFileArgsSchema.safeParse(args); "edit_file",
if (!parsed.success) { {
throw new Error(`Invalid arguments for edit_file: ${parsed.error}`); title: "Edit File",
description:
"Make line-based edits to a text file. Each edit replaces exact line sequences " +
"with new content. Returns a git-style diff showing the changes made. " +
"Only works within allowed directories.",
inputSchema: {
path: z.string(),
edits: z.array(z.object({
oldText: z.string().describe("Text to search for - must match exactly"),
newText: z.string().describe("Text to replace with")
})),
dryRun: z.boolean().default(false).describe("Preview changes using git-style diff format")
},
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
} }
const validPath = await validatePath(parsed.data.path); },
const result = await applyFileEdits(validPath, parsed.data.edits, parsed.data.dryRun); async (args: z.infer<typeof EditFileArgsSchema>) => {
const validPath = await validatePath(args.path);
const result = await applyFileEdits(validPath, args.edits, args.dryRun);
return { return {
content: [{ type: "text", text: result }], content: [{ type: "text" as const, text: result }],
}; };
} }
);
case "create_directory": { server.registerTool(
const parsed = CreateDirectoryArgsSchema.safeParse(args); "create_directory",
if (!parsed.success) { {
throw new Error(`Invalid arguments for create_directory: ${parsed.error}`); 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: {
path: z.string()
},
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
} }
const validPath = await validatePath(parsed.data.path); },
async (args: z.infer<typeof CreateDirectoryArgsSchema>) => {
const validPath = await validatePath(args.path);
await fs.mkdir(validPath, { recursive: true }); await fs.mkdir(validPath, { recursive: true });
return { return {
content: [{ type: "text", text: `Successfully created directory ${parsed.data.path}` }], content: [{ type: "text" as const, text: `Successfully created directory ${args.path}` }],
}; };
} }
);
case "list_directory": { server.registerTool(
const parsed = ListDirectoryArgsSchema.safeParse(args); "list_directory",
if (!parsed.success) { {
throw new Error(`Invalid arguments for list_directory: ${parsed.error}`); title: "List Directory",
description:
"Get a detailed listing of all files and directories in a specified path. " +
"Results clearly distinguish between files and directories with [FILE] and [DIR] " +
"prefixes. This tool is essential for understanding directory structure and " +
"finding specific files within a directory. Only works within allowed directories.",
inputSchema: {
path: z.string()
},
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
} }
const validPath = await validatePath(parsed.data.path); },
async (args: z.infer<typeof ListDirectoryArgsSchema>) => {
const validPath = await validatePath(args.path);
const entries = await fs.readdir(validPath, { withFileTypes: true }); const entries = await fs.readdir(validPath, { withFileTypes: true });
const formatted = entries const formatted = entries
.map((entry) => `${entry.isDirectory() ? "[DIR]" : "[FILE]"} ${entry.name}`) .map((entry) => `${entry.isDirectory() ? "[DIR]" : "[FILE]"} ${entry.name}`)
.join("\n"); .join("\n");
return { return {
content: [{ type: "text", text: formatted }], content: [{ type: "text" as const, text: formatted }],
}; };
} }
);
case "list_directory_with_sizes": { server.registerTool(
const parsed = ListDirectoryWithSizesArgsSchema.safeParse(args); "list_directory_with_sizes",
if (!parsed.success) { {
throw new Error(`Invalid arguments for list_directory_with_sizes: ${parsed.error}`); title: "List Directory with Sizes",
description:
"Get a detailed listing of all files and directories in a specified path, including sizes. " +
"Results clearly distinguish between files and directories with [FILE] and [DIR] " +
"prefixes. This tool is useful for understanding directory structure and " +
"finding specific files within a directory. Only works within allowed directories.",
inputSchema: {
path: z.string(),
sortBy: z.enum(["name", "size"]).optional().default("name").describe("Sort entries by name or size")
},
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
} }
const validPath = await validatePath(parsed.data.path); },
async (args: z.infer<typeof ListDirectoryWithSizesArgsSchema>) => {
const validPath = await validatePath(args.path);
const entries = await fs.readdir(validPath, { withFileTypes: true }); const entries = await fs.readdir(validPath, { withFileTypes: true });
// Get detailed information for each entry // Get detailed information for each entry
@@ -489,7 +501,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
// Sort entries based on sortBy parameter // Sort entries based on sortBy parameter
const sortedEntries = [...detailedEntries].sort((a, b) => { const sortedEntries = [...detailedEntries].sort((a, b) => {
if (parsed.data.sortBy === 'size') { if (args.sortBy === 'size') {
return b.size - a.size; // Descending by size return b.size - a.size; // Descending by size
} }
// Default sort by name // Default sort by name
@@ -516,24 +528,40 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
return { return {
content: [{ content: [{
type: "text", type: "text" as const,
text: [...formattedEntries, ...summary].join("\n") text: [...formattedEntries, ...summary].join("\n")
}], }],
}; };
} }
);
case "directory_tree": { server.registerTool(
const parsed = DirectoryTreeArgsSchema.safeParse(args); "directory_tree",
if (!parsed.success) { {
throw new Error(`Invalid arguments for directory_tree: ${parsed.error}`); 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). " +
"The output is formatted with 2-space indentation for readability. Only works within allowed directories.",
inputSchema: {
path: z.string(),
excludePatterns: z.array(z.string()).optional().default([])
},
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
} }
},
async (args: z.infer<typeof DirectoryTreeArgsSchema>) => {
interface TreeEntry { interface TreeEntry {
name: string; name: string;
type: 'file' | 'directory'; type: 'file' | 'directory';
children?: TreeEntry[]; children?: TreeEntry[];
} }
const rootPath = parsed.data.path; const rootPath = args.path;
async function buildTree(currentPath: string, excludePatterns: string[] = []): Promise<TreeEntry[]> { async function buildTree(currentPath: string, excludePatterns: string[] = []): Promise<TreeEntry[]> {
const validPath = await validatePath(currentPath); const validPath = await validatePath(currentPath);
@@ -571,74 +599,133 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
return result; return result;
} }
const treeData = await buildTree(rootPath, parsed.data.excludePatterns); const treeData = await buildTree(rootPath, args.excludePatterns);
return { return {
content: [{ content: [{
type: "text", type: "text" as const,
text: JSON.stringify(treeData, null, 2) text: JSON.stringify(treeData, null, 2)
}], }],
}; };
} }
);
case "move_file": { server.registerTool(
const parsed = MoveFileArgsSchema.safeParse(args); "move_file",
if (!parsed.success) { {
throw new Error(`Invalid arguments for move_file: ${parsed.error}`); 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: {
source: z.string(),
destination: z.string()
},
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
} }
const validSourcePath = await validatePath(parsed.data.source); },
const validDestPath = await validatePath(parsed.data.destination); async (args: z.infer<typeof MoveFileArgsSchema>) => {
const validSourcePath = await validatePath(args.source);
const validDestPath = await validatePath(args.destination);
await fs.rename(validSourcePath, validDestPath); await fs.rename(validSourcePath, validDestPath);
return { return {
content: [{ type: "text", text: `Successfully moved ${parsed.data.source} to ${parsed.data.destination}` }], content: [{ type: "text" as const, text: `Successfully moved ${args.source} to ${args.destination}` }],
}; };
} }
);
case "search_files": { server.registerTool(
const parsed = SearchFilesArgsSchema.safeParse(args); "search_files",
if (!parsed.success) { {
throw new Error(`Invalid arguments for search_files: ${parsed.error}`); title: "Search Files",
description:
"Recursively search for files and directories matching a pattern. " +
"The patterns should be glob-style patterns that match paths relative to the working directory. " +
"Use pattern like '*.ext' to match files in current directory, and '**/*.ext' to match files in all subdirectories. " +
"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: {
path: z.string(),
pattern: z.string(),
excludePatterns: z.array(z.string()).optional().default([])
},
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
} }
const validPath = await validatePath(parsed.data.path); },
const results = await searchFilesWithValidation(validPath, parsed.data.pattern, allowedDirectories, { excludePatterns: parsed.data.excludePatterns }); async (args: z.infer<typeof SearchFilesArgsSchema>) => {
const validPath = await validatePath(args.path);
const results = await searchFilesWithValidation(validPath, args.pattern, allowedDirectories, { excludePatterns: args.excludePatterns });
return { return {
content: [{ type: "text", text: results.length > 0 ? results.join("\n") : "No matches found" }], content: [{ type: "text" as const, text: results.length > 0 ? results.join("\n") : "No matches found" }],
}; };
} }
);
case "get_file_info": { server.registerTool(
const parsed = GetFileInfoArgsSchema.safeParse(args); "get_file_info",
if (!parsed.success) { {
throw new Error(`Invalid arguments for get_file_info: ${parsed.error}`); title: "Get File Info",
description:
"Retrieve detailed metadata about a file or directory. Returns comprehensive " +
"information including size, creation time, last modified time, permissions, " +
"and type. This tool is perfect for understanding file characteristics " +
"without reading the actual content. Only works within allowed directories.",
inputSchema: {
path: z.string()
},
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
} }
const validPath = await validatePath(parsed.data.path); },
async (args: z.infer<typeof GetFileInfoArgsSchema>) => {
const validPath = await validatePath(args.path);
const info = await getFileStats(validPath); const info = await getFileStats(validPath);
return { return {
content: [{ type: "text", text: Object.entries(info) content: [{ type: "text" as const, text: Object.entries(info)
.map(([key, value]) => `${key}: ${value}`) .map(([key, value]) => `${key}: ${value}`)
.join("\n") }], .join("\n") }],
}; };
} }
);
case "list_allowed_directories": { 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: {},
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
}
},
async () => {
return { return {
content: [{ content: [{
type: "text", type: "text" as const,
text: `Allowed directories:\n${allowedDirectories.join('\n')}` text: `Allowed directories:\n${allowedDirectories.join('\n')}`
}], }],
}; };
} }
);
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text", text: `Error: ${errorMessage}` }],
isError: true,
};
}
});
// Updates allowed directories based on MCP client roots // Updates allowed directories based on MCP client roots
async function updateAllowedDirectoriesFromRoots(requestedRoots: Root[]) { async function updateAllowedDirectoriesFromRoots(requestedRoots: Root[]) {
@@ -653,10 +740,10 @@ async function updateAllowedDirectoriesFromRoots(requestedRoots: Root[]) {
} }
// 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. // 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.setNotificationHandler(RootsListChangedNotificationSchema, async () => { server.server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
try { try {
// Request the updated roots list from the client // Request the updated roots list from the client
const response = await server.listRoots(); const response = await server.server.listRoots();
if (response && 'roots' in response) { if (response && 'roots' in response) {
await updateAllowedDirectoriesFromRoots(response.roots); await updateAllowedDirectoriesFromRoots(response.roots);
} }
@@ -666,12 +753,12 @@ server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
}); });
// Handles post-initialization setup, specifically checking for and fetching MCP roots. // Handles post-initialization setup, specifically checking for and fetching MCP roots.
server.oninitialized = async () => { server.server.oninitialized = async () => {
const clientCapabilities = server.getClientCapabilities(); const clientCapabilities = server.server.getClientCapabilities();
if (clientCapabilities?.roots) { if (clientCapabilities?.roots) {
try { try {
const response = await server.listRoots(); const response = await server.server.listRoots();
if (response && 'roots' in response) { if (response && 'roots' in response) {
await updateAllowedDirectoriesFromRoots(response.roots); await updateAllowedDirectoriesFromRoots(response.roots);
} else { } else {

View File

@@ -12,6 +12,7 @@
"exclude": [ "exclude": [
"**/__tests__/**", "**/__tests__/**",
"**/*.test.ts", "**/*.test.ts",
"**/*.spec.ts" "**/*.spec.ts",
"vitest.config.ts"
] ]
} }