https://github.com/modelcontextprotocol/servers 展示了MCP(Model Context Protocol)模型上下文协议的多功能性和可扩展性,演示了如何使用它为大型语言模型(LLMs)提供对工具和数据源的安全、受控访问。每个MCP服务端都使用Typescript MCP SDK或Python MCP SDK实现。
本文将以Filesystem 为例,介绍MCP服务端所需的基本功能。
Filesystem MCP Server 实现了一个安全的文件系统服务器,使用 Node.js 和 @modelcontextprotocol/sdk 库来处理文件操作请求。服务器通过命令行参数指定允许访问的目录,并提供了一系列工具来执行文件读取、写入、编辑、目录创建、文件移动、文件搜索和文件信息获取等操作。
命令行参数解析
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Usage: mcp-server-filesystem <allowed-directory> [additional-directories...]");
process.exit(1);
}
-
代码首先解析命令行参数,检查是否提供了允许访问的目录。如果没有提供目录,则打印使用说明并退出程序。
路径处理和验证
function normalizePath(p: string): string {
return path.normalize(p).toLowerCase();
}
function expandHome(filepath: string): string {
if (filepath.startsWith('~/') || filepath === '~') {
return path.join(os.homedir(), filepath.slice(1));
}
return filepath;
}
const allowedDirectories = args.map(dir =>
normalizePath(path.resolve(expandHome(dir)))
);
await Promise.all(args.map(async (dir) => {
try {
const stats = await fs.stat(dir);
if (!stats.isDirectory()) {
console.error(`Error: ${dir} is not a directory`);
process.exit(1);
}
} catch (error) {
console.error(`Error accessing directory ${dir}:`, error);
process.exit(1);
}
}));
-
normalizePath函数将路径标准化并转换为小写,以便进行一致的比较。 -
expandHome函数将路径中的~扩展为用户主目录。 -
allowedDirectories存储所有允许访问的目录,并进行路径标准化和验证。 -
代码验证所有提供的目录是否存在且为目录,如果不存在或不是目录,则退出程序。
路径验证函数
async function validatePath(requestedPath: string): Promise<string> {
const expandedPath = expandHome(requestedPath);
const absolute = path.isAbsolute(expandedPath)
? path.resolve(expandedPath)
: path.resolve(process.cwd(), expandedPath);
const normalizedRequested = normalizePath(absolute);
const isAllowed = allowedDirectories.some(dir => normalizedRequested.startsWith(dir));
if (!isAllowed) {
throw new Error(`Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(', ')}`);
}
try {
const realPath = await fs.realpath(absolute);
const normalizedReal = normalizePath(realPath);
const isRealPathAllowed = allowedDirectories.some(dir => normalizedReal.startsWith(dir));
if (!isRealPathAllowed) {
throw new Error("Access denied - symlink target outside allowed directories");
}
return realPath;
} catch (error) {
const parentDir = path.dirname(absolute);
try {
const realParentPath = await fs.realpath(parentDir);
const normalizedParent = normalizePath(realParentPath);
const isParentAllowed = allowedDirectories.some(dir => normalizedParent.startsWith(dir));
if (!isParentAllowed) {
throw new Error("Access denied - parent directory outside allowed directories");
}
return absolute;
} catch {
throw new Error(`Parent directory does not exist: ${parentDir}`);
}
}
}
-
validatePath函数验证请求的路径是否在允许的目录范围内,并处理符号链接。 -
如果路径不在允许范围内,或者符号链接的目标路径不在允许范围内,则抛出错误。
工具和请求处理
const server = new Server(
{
name: "secure-filesystem-server",
version: "0.2.0",
},
{
capabilities: {
tools: {},
},
},
);
-
创建一个
Server实例,指定服务器的名称和版本。
工具实现
async function getFileStats(filePath: string): Promise<FileInfo> {
const stats = await fs.stat(filePath);
return {
size: stats.size,
created: stats.birthtime,
modified: stats.mtime,
accessed: stats.atime,
isDirectory: stats.isDirectory(),
isFile: stats.isFile(),
permissions: stats.mode.toString(8).slice(-3),
};
}
async function searchFiles(
rootPath: string,
pattern: string,
): Promise<string[]> {
const results: string[] = [];
async function search(currentPath: string) {
const entries = await fs.readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name);
try {
await validatePath(fullPath);
if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {
results.push(fullPath);
}
if (entry.isDirectory()) {
await search(fullPath);
}
} catch (error) {
continue;
}
}
}
await search(rootPath);
return results;
}
-
getFileStats函数获取文件的详细信息,包括大小、创建时间、修改时间、访问时间、是否为目录、是否为文件以及权限。 -
searchFiles函数递归搜索指定目录及其子目录,查找匹配指定模式的文件和目录。
文件编辑和差异生成
function normalizeLineEndings(text: string): string {
return text.replace(/\r\n/g, '\n');
}
function createUnifiedDiff(originalContent: string, newContent: string, filepath: string = 'file'): string {
const normalizedOriginal = normalizeLineEndings(originalContent);
const normalizedNew = normalizeLineEndings(newContent);
return createTwoFilesPatch(
filepath,
filepath,
normalizedOriginal,
normalizedNew,
'original',
'modified'
);
}
async function applyFileEdits(
filePath: string,
edits: Array<{oldText: string, newText: string}>,
dryRun = false
): Promise<string> {
const content = normalizeLineEndings(await fs.readFile(filePath, 'utf-8'));
let modifiedContent = content;
for (const edit of edits) {
const normalizedOld = normalizeLineEndings(edit.oldText);
const normalizedNew = normalizeLineEndings(edit.newText);
if (modifiedContent.includes(normalizedOld)) {
modifiedContent = modifiedContent.replace(normalizedOld, normalizedNew);
continue;
}
const oldLines = normalizedOld.split('\n');
const contentLines = modifiedContent.split('\n');
let matchFound = false;
for (let i = 0; i <= contentLines.length - oldLines.length; i++) {
const potentialMatch = contentLines.slice(i, i + oldLines.length);
const isMatch = oldLines.every((oldLine, j) => {
const contentLine = potentialMatch[j];
return oldLine.trim() === contentLine.trim();
});
if (isMatch) {
const originalIndent = contentLines[i].match(/^\s*/)?.[0] || '';
const newLines = normalizedNew.split('\n').map((line, j) => {
if (j === 0) return originalIndent + line.trimStart();
const oldIndent = oldLines[j]?.match(/^\s*/)?.[0] || '';
const newIndent = line.match(/^\s*/)?.[0] || '';
if (oldIndent && newIndent) {
const relativeIndent = newIndent.length - oldIndent.length;
return originalIndent + ' '.repeat(Math.max(0, relativeIndent)) + line.trimStart();
}
return line;
});
contentLines.splice(i, oldLines.length, ...newLines);
modifiedContent = contentLines.join('\n');
matchFound = true;
break;
}
}
if (!matchFound) {
throw new Error(`Could not find exact match for edit:\n${edit.oldText}`);
}
}
const diff = createUnifiedDiff(content, modifiedContent, filePath);
let numBackticks = 3;
while (diff.includes('`'.repeat(numBackticks))) {
numBackticks++;
}
const formattedDiff = `${'`'.repeat(numBackticks)}diff\n${diff}${'`'.repeat(numBackticks)}\n\n`;
if (!dryRun) {
await fs.writeFile(filePath, modifiedContent, 'utf-8');
}
return formattedDiff;
}
-
normalizeLineEndings函数将文本中的所有换行符统一为\n。 -
createUnifiedDiff函数生成两个文件内容的统一差异。 -
applyFileEdits函数应用一系列编辑操作到文件内容,并生成差异报告。如果dryRun为true,则只生成差异报告而不实际写入文件。
工具请求处理
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "read_file",
description:
"Read the complete contents of a file from the file system. " +
"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. Only works within allowed directories.",
inputSchema: zodToJsonSchema(ReadFileArgsSchema) as ToolInput,
},
// ... 其他工具的定义 ...
],
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params;
switch (name) {
case "read_file": {
const parsed = ReadFileArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for read_file: ${parsed.error}`);
}
const validPath = await validatePath(parsed.data.path);
const content = await fs.readFile(validPath, "utf-8");
return {
content: [{ type: "text", text: content }],
};
}
// ... 其他工具的处理 ...
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,
};
}
});
-
setRequestHandler方法为ListToolsRequestSchema和CallToolRequestSchema设置请求处理函数。 -
对于每个工具,代码解析请求参数,验证路径,执行相应的文件操作,并返回结果。
启动服务器
async function runServer() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Secure MCP Filesystem Server running on stdio");
console.error("Allowed directories:", allowedDirectories);
}
runServer().catch((error) => {
console.error("Fatal error running server:", error);
process.exit(1);
});
-
runServer函数创建StdioServerTransport实例,并启动服务器。 -
如果启动过程中发生错误,则捕获错误并退出程序。
总结
这是一个基于Node.js的文件系统MCP服务端,实现了Model Context Protocol (MCP),用于执行文件系统操作。它支持读写文件、创建/列出/删除目录、移动文件/目录、搜索文件以及获取文件元数据等功能。服务器仅允许在指定的目录范围内进行操作。该服务器通过MCP协议与Claude Desktop等工具集成,方便进行文件管理。项目采用MIT许可证,用户可以自由使用、修改和分发。
MCP Servers 官方库 https://github.com/modelcontextprotocol/servers 提供了一系列功能丰富的MCP服务器,均采用宽松的许可证,非常值得尝试。
9387

被折叠的 条评论
为什么被折叠?



