mirror of
https://github.com/modelcontextprotocol/servers.git
synced 2026-04-20 12:55:36 +02:00
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:
@@ -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,41 +528,57 @@ 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);
|
||||||
const entries = await fs.readdir(validPath, {withFileTypes: true});
|
const entries = await fs.readdir(validPath, { withFileTypes: true });
|
||||||
const result: TreeEntry[] = [];
|
const result: TreeEntry[] = [];
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const relativePath = path.relative(rootPath, path.join(currentPath, entry.name));
|
const relativePath = path.relative(rootPath, path.join(currentPath, entry.name));
|
||||||
const shouldExclude = excludePatterns.some(pattern => {
|
const shouldExclude = excludePatterns.some(pattern => {
|
||||||
if (pattern.includes('*')) {
|
if (pattern.includes('*')) {
|
||||||
return minimatch(relativePath, pattern, {dot: true});
|
return minimatch(relativePath, pattern, { dot: true });
|
||||||
}
|
}
|
||||||
// For files: match exact name or as part of path
|
// For files: match exact name or as part of path
|
||||||
// For directories: match as directory path
|
// For directories: match as directory path
|
||||||
return minimatch(relativePath, pattern, {dot: true}) ||
|
return minimatch(relativePath, pattern, { dot: true }) ||
|
||||||
minimatch(relativePath, `**/${pattern}`, {dot: true}) ||
|
minimatch(relativePath, `**/${pattern}`, { dot: true }) ||
|
||||||
minimatch(relativePath, `**/${pattern}/**`, {dot: true});
|
minimatch(relativePath, `**/${pattern}/**`, { dot: true });
|
||||||
});
|
});
|
||||||
if (shouldExclude)
|
if (shouldExclude)
|
||||||
continue;
|
continue;
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"exclude": [
|
"exclude": [
|
||||||
"**/__tests__/**",
|
"**/__tests__/**",
|
||||||
"**/*.test.ts",
|
"**/*.test.ts",
|
||||||
"**/*.spec.ts"
|
"**/*.spec.ts",
|
||||||
|
"vitest.config.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user