📦 build: 初始化 Claude Code Skills 项目结构
添加 FileSystem MCP 服务器的完整实现,包括: - 基于 MCP SDK 的 TypeScript 服务器 - 三个核心工具:read_file, list_directory, search_files - 双传输模式支持(stdio 和 HTTP) - 完整的类型安全和输入验证 - 项目文档和构建配置 Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
commit
f25d790d6f
|
|
@ -0,0 +1,13 @@
|
|||
# Skills bundled resources
|
||||
.claude
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# OS
|
||||
node_modules/
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a Claude Code Skills repository containing two main components:
|
||||
1. **FileSystem MCP Server** (`filesystem-mcp-server/`) - A Model Context Protocol server implementation in TypeScript
|
||||
2. **Claude Skills** (`.claude/skills/`) - 16 modular skills that extend Claude's capabilities
|
||||
|
||||
## Build Commands
|
||||
|
||||
All commands run from `filesystem-mcp-server/` directory:
|
||||
|
||||
```bash
|
||||
# Build the TypeScript MCP server
|
||||
npm run build
|
||||
|
||||
# Development mode with hot reload
|
||||
npm run dev
|
||||
|
||||
# Start the server (after build)
|
||||
npm start
|
||||
|
||||
# Clean build artifacts
|
||||
npm run clean
|
||||
```
|
||||
|
||||
**Environment Variables:**
|
||||
- `TRANSPORT` - Transport mode (`stdio` or `http`, default: `http`)
|
||||
- `PORT` - HTTP port (default: `3000`)
|
||||
|
||||
## Architecture
|
||||
|
||||
### MCP Server (`filesystem-mcp-server/`)
|
||||
- **Transport**: Dual support (stdio for local, HTTP for remote)
|
||||
- **Entry point**: `src/index.ts` - handles transport selection
|
||||
- **Tools**: Modular pattern in `src/tools/` - each tool in its own file
|
||||
- **Validation**: Zod schemas for all tool inputs
|
||||
- **Type safety**: Strict TypeScript with `src/types.ts` and `src/constants.ts`
|
||||
|
||||
### Skills (`.claude/skills/`)
|
||||
Each skill follows a standard structure:
|
||||
- `SKILL.md` - Main documentation with YAML frontmatter (`name`, `description`)
|
||||
- Optional: `scripts/`, `references/`, `assets/` folders
|
||||
- Most skills reference Python libraries (pypdf, reportlab, python-pptx, openpyxl, etc.)
|
||||
|
||||
## Key Skills
|
||||
- `mcp-builder` - Guides creating MCP servers with TypeScript/Python SDKs
|
||||
- `skill-creator` - Guides creating new skills (6-step process)
|
||||
|
||||
## Important Notes
|
||||
- No `package.json` at root - each component is separate
|
||||
- MCP server uses ES modules (`"type": "module"`)
|
||||
- No existing Cursor/Copilot rules files
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
# FileSystem MCP Server
|
||||
|
||||
一个基于 Model Context Protocol (MCP) 的文件系统服务器,提供与本地文件系统交互的工具。
|
||||
|
||||
## 概述
|
||||
|
||||
FileSystem MCP Server 是一个 Node.js/TypeScript 实现的服务端应用,通过 MCP 协议使 AI 模型能够安全地读取、浏览和搜索本地文件系统。该服务器提供三个核心工具:`read_file`、`list_directory` 和 `search_files`,支持多种输出格式和输入验证。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **安全文件读取** - 支持指定编码、行范围和字符限制的文本读取
|
||||
- **目录内容浏览** - 列出目录中的文件和子目录,包含元数据
|
||||
- **模式匹配搜索** - 使用 glob 模式递归搜索文件
|
||||
- **双传输模式** - 支持 stdio(本地)和 HTTP(远程)两种传输方式
|
||||
- **类型安全** - 完整的 TypeScript 类型定义和 Zod 输入验证
|
||||
|
||||
## 系统要求
|
||||
|
||||
- Node.js >= 18
|
||||
- npm 或 yarn
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
cd filesystem-mcp-server
|
||||
npm install
|
||||
```
|
||||
|
||||
### 构建
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 运行
|
||||
|
||||
**stdio 模式(默认,本地集成)**
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
**HTTP 模式(远程访问)**
|
||||
|
||||
```bash
|
||||
TRANSPORT=http npm start
|
||||
```
|
||||
|
||||
**开发模式(热重载)**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
HTTP 模式默认监听 3000 端口,可通过 `PORT` 环境变量指定其他端口:
|
||||
|
||||
```bash
|
||||
PORT=8080 TRANSPORT=http npm start
|
||||
```
|
||||
|
||||
## 工具说明
|
||||
|
||||
### read_file
|
||||
|
||||
读取指定路径的文件内容。
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必需 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| `path` | string | 是 | - | 文件的绝对路径 |
|
||||
| `encoding` | string | 否 | `utf-8` | 文件编码 |
|
||||
| `max_chars` | number | 否 | - | 返回的最大字符数 |
|
||||
| `line_start` | number | 否 | - | 起始行号(1-based) |
|
||||
| `line_end` | number | 否 | - | 结束行号(1-based) |
|
||||
| `response_format` | string | 否 | `markdown` | 输出格式:`markdown` 或 `json` |
|
||||
|
||||
**示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "/path/to/config.json",
|
||||
"encoding": "utf-8",
|
||||
"line_start": 1,
|
||||
"line_end": 100,
|
||||
"response_format": "markdown"
|
||||
}
|
||||
```
|
||||
|
||||
**输出格式**
|
||||
|
||||
- Markdown:人类可读的格式化输出,包含文件信息和代码块
|
||||
- JSON:包含完整元数据的结构化数据
|
||||
|
||||
---
|
||||
|
||||
### list_directory
|
||||
|
||||
列出指定目录的内容。
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必需 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| `path` | string | 是 | - | 目录的绝对路径 |
|
||||
| `response_format` | string | 否 | `markdown` | 输出格式:`markdown` 或 `json` |
|
||||
|
||||
**示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "/path/to/project",
|
||||
"response_format": "markdown"
|
||||
}
|
||||
```
|
||||
|
||||
**输出格式**
|
||||
|
||||
- Markdown:人类可读的目录结构,使用图标标识文件和目录
|
||||
- JSON:包含文件和目录详细信息的结构化数据
|
||||
|
||||
---
|
||||
|
||||
### search_files
|
||||
|
||||
使用 glob 模式在目录中搜索文件。
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必需 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| `path` | string | 是 | - | 搜索起始目录的绝对路径 |
|
||||
| `pattern` | string | 是 | - | glob 模式(如 `*.ts`、`**/*.json`) |
|
||||
| `max_results` | number | 否 | 50 | 返回的最大结果数(最大 100) |
|
||||
| `include_content` | boolean | 否 | `false` | 是否在结果中包含文件内容 |
|
||||
| `response_format` | string | 否 | `markdown` | 输出格式:`markdown` 或 `json` |
|
||||
|
||||
**模式示例**
|
||||
|
||||
| 模式 | 匹配 |
|
||||
|------|------|
|
||||
| `*.ts` | 当前目录下的所有 TypeScript 文件 |
|
||||
| `**/*.json` | 任意目录下的所有 JSON 文件 |
|
||||
| `src/**/*.ts` | src 目录下的所有 TypeScript 文件 |
|
||||
| `*.{js,ts}` | 当前目录下的所有 JS 和 TS 文件 |
|
||||
|
||||
**示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "/path/to/project",
|
||||
"pattern": "**/*.ts",
|
||||
"max_results": 50,
|
||||
"include_content": false,
|
||||
"response_format": "json"
|
||||
}
|
||||
```
|
||||
|
||||
**输出格式**
|
||||
|
||||
- Markdown:人类可读的搜索结果列表
|
||||
- JSON:包含所有匹配文件详细信息的结构化数据
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| `TRANSPORT` | `stdio` 或 `http` | 传输协议,默认 `stdio` |
|
||||
| `PORT` | 端口号 | HTTP 模式监听的端口,默认 `3000` |
|
||||
|
||||
## 在 MCP 客户端中使用
|
||||
|
||||
### Claude Desktop 配置
|
||||
|
||||
在 Claude Desktop 的配置文件中添加:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/filesystem-mcp-server/dist/index.js"],
|
||||
"disabled": false,
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
如需使用 HTTP 模式:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/filesystem-mcp-server/dist/index.js"],
|
||||
"disabled": false,
|
||||
"env": {
|
||||
"TRANSPORT": "http",
|
||||
"PORT": "3000"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
filesystem-mcp-server/
|
||||
├── package.json # 项目配置和依赖
|
||||
├── tsconfig.json # TypeScript 编译配置
|
||||
├── README.md # 项目文档
|
||||
├── src/
|
||||
│ ├── index.ts # 服务器入口点
|
||||
│ ├── types.ts # TypeScript 类型定义
|
||||
│ ├── constants.ts # 常量配置
|
||||
│ └── tools/
|
||||
│ ├── index.ts # 工具注册
|
||||
│ ├── read-file.ts # 读取文件工具实现
|
||||
│ ├── list-directory.ts # 目录列表工具实现
|
||||
│ └── search-files.ts # 文件搜索工具实现
|
||||
└── dist/ # 编译输出
|
||||
```
|
||||
|
||||
## 开发
|
||||
|
||||
### 可用脚本
|
||||
|
||||
```bash
|
||||
# 构建项目
|
||||
npm run build
|
||||
|
||||
# 开发模式(热重载)
|
||||
npm run dev
|
||||
|
||||
# 清理构建产物
|
||||
npm run clean
|
||||
```
|
||||
|
||||
### 添加新工具
|
||||
|
||||
1. 在 `src/tools/` 目录中创建新的工具文件
|
||||
2. 使用 `server.registerTool()` 注册工具
|
||||
3. 在 `src/tools/index.ts` 中导出工具注册函数
|
||||
|
||||
## 错误处理
|
||||
|
||||
所有工具都提供清晰的错误信息:
|
||||
|
||||
| 错误类型 | 错误信息 |
|
||||
|---------|---------|
|
||||
| 文件不存在 | `Error: File not found: <path>` |
|
||||
| 目录不存在 | `Error: Directory not found: <path>` |
|
||||
| 权限被拒 | `Error: Permission denied to <operation>: <path>` |
|
||||
| 路径是文件 | `Error: Path is a file, not a directory: <path>` |
|
||||
| 路径是目录 | `Error: Path is a directory, not a file: <path>` |
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "filesystem-mcp-server",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server for filesystem operations",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.6.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.10.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Constants for the FileSystem MCP Server.
|
||||
*/
|
||||
|
||||
export const CHARACTER_LIMIT = 25000;
|
||||
|
||||
export const DEFAULT_ENCODING = "utf-8";
|
||||
|
||||
export const MAX_DEPTH = 10;
|
||||
|
||||
export const DEFAULT_FILE_LIMIT = 100;
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* FileSystem MCP Server.
|
||||
*
|
||||
* This server provides tools to interact with the local filesystem,
|
||||
* including reading files, listing directories, and searching for files.
|
||||
*/
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import express from "express";
|
||||
import { registerAllTools } from "./tools/index.js";
|
||||
|
||||
// Create MCP server instance
|
||||
const server = new McpServer({
|
||||
name: "filesystem-mcp",
|
||||
version: "1.0.0"
|
||||
});
|
||||
|
||||
// Register all tools
|
||||
registerAllTools(server);
|
||||
|
||||
// Run with stdio transport (default, for local integration)
|
||||
async function runStdio() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error("FileSystem MCP server running via stdio");
|
||||
}
|
||||
|
||||
// Run with HTTP transport (for remote access)
|
||||
async function runHTTP() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.post('/mcp', async (req, res) => {
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
enableJsonResponse: true
|
||||
});
|
||||
|
||||
res.on('close', () => transport.close());
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
});
|
||||
|
||||
const port = parseInt(process.env.PORT || '7666');
|
||||
app.listen(port, () => {
|
||||
console.error(`FileSystem MCP server running on http://localhost:${port}/mcp`);
|
||||
});
|
||||
}
|
||||
|
||||
// Choose transport based on environment
|
||||
const transport = process.env.TRANSPORT || 'http';
|
||||
|
||||
if (transport === 'http') {
|
||||
runHTTP().catch(error => {
|
||||
console.error("Server error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
} else {
|
||||
runStdio().catch(error => {
|
||||
console.error("Server error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Tool exports and registration for the FileSystem MCP Server.
|
||||
*/
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { registerReadFileTool } from "./read-file.js";
|
||||
import { registerListDirectoryTool } from "./list-directory.js";
|
||||
import { registerSearchFilesTool } from "./search-files.js";
|
||||
|
||||
export function registerAllTools(server: McpServer) {
|
||||
registerReadFileTool(server);
|
||||
registerListDirectoryTool(server);
|
||||
registerSearchFilesTool(server);
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { promises as fs } from "fs";
|
||||
import { lstat } from "fs/promises";
|
||||
import { DEFAULT_FILE_LIMIT } from "../constants.js";
|
||||
import type { DirectoryEntry } from "../types.js";
|
||||
|
||||
const ListDirectoryInputSchema = z.object({
|
||||
path: z.string()
|
||||
.min(1, "Path is required")
|
||||
.describe("Absolute path to the directory to list"),
|
||||
response_format: z.enum(["markdown", "json"])
|
||||
.default("markdown")
|
||||
.describe("Output format: 'markdown' for human-readable or 'json' for machine-readable")
|
||||
}).strict();
|
||||
|
||||
type ListDirectoryInput = z.infer<typeof ListDirectoryInputSchema>;
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
export function registerListDirectoryTool(server: McpServer) {
|
||||
server.registerTool(
|
||||
"list_directory",
|
||||
{
|
||||
title: "List Directory",
|
||||
description: `List the contents of a directory in the filesystem.
|
||||
|
||||
This tool returns a list of all files and subdirectories within a specified directory. It provides metadata including file sizes and modification times.
|
||||
|
||||
Args:
|
||||
- path (string): Absolute path to the directory to list
|
||||
- response_format ('markdown' | 'json'): Output format (default: 'markdown')
|
||||
|
||||
Returns:
|
||||
For JSON format: Structured data with schema:
|
||||
{
|
||||
"path": string, // Path to the directory
|
||||
"total_items": number, // Total number of items
|
||||
"directories": [ // List of subdirectories
|
||||
{
|
||||
"name": string,
|
||||
"path": string,
|
||||
"isDirectory": true,
|
||||
"size": number, // Always 0 for directories
|
||||
"modified": string // ISO date string
|
||||
}
|
||||
],
|
||||
"files": [ // List of files
|
||||
{
|
||||
"name": string,
|
||||
"path": string,
|
||||
"isDirectory": false,
|
||||
"size": number, // File size in bytes
|
||||
"modified": string // ISO date string
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Examples:
|
||||
- Use when: "List all files in /etc" -> params with path="/etc"
|
||||
- Use when: "Show contents of the project src folder" -> params with path="/path/to/project/src"
|
||||
- Don't use when: You need to read file contents (use read_file instead)
|
||||
- Don't use when: You need to search for specific files (use search_files instead)
|
||||
|
||||
Error Handling:
|
||||
- Returns "Error: Directory not found" if the path does not exist (ENOENT)
|
||||
- Returns "Error: Path is a file" if the path is a file, not a directory
|
||||
- Returns "Error: Permission denied" if the directory cannot be read (EACCES)`,
|
||||
inputSchema: ListDirectoryInputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false
|
||||
}
|
||||
},
|
||||
async (params: ListDirectoryInput) => {
|
||||
try {
|
||||
// Check if path exists and get stats
|
||||
const stats = await lstat(params.path);
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Error: Path is a file, not a directory: ${params.path}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
// Read directory contents
|
||||
const entries = await fs.readdir(params.path, { withFileTypes: true });
|
||||
|
||||
const directories: DirectoryEntry[] = [];
|
||||
const files: DirectoryEntry[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = `${params.path}/${entry.name}`;
|
||||
const entryStats = await lstat(entryPath);
|
||||
|
||||
const entryInfo: DirectoryEntry = {
|
||||
name: entry.name,
|
||||
path: entryPath,
|
||||
isDirectory: entry.isDirectory(),
|
||||
size: entryStats.size,
|
||||
modified: entryStats.mtime.toISOString()
|
||||
};
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
directories.push(entryInfo);
|
||||
} else {
|
||||
files.push(entryInfo);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort alphabetically
|
||||
directories.sort((a, b) => a.name.localeCompare(b.name));
|
||||
files.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const output = {
|
||||
path: params.path,
|
||||
total_items: directories.length + files.length,
|
||||
directories,
|
||||
files
|
||||
};
|
||||
|
||||
let textContent: string;
|
||||
if (params.response_format === "json") {
|
||||
textContent = JSON.stringify(output, null, 2);
|
||||
} else {
|
||||
const lines: string[] = [
|
||||
`# Directory: ${params.path}`,
|
||||
"",
|
||||
`Total: ${directories.length} directories, ${files.length} files`,
|
||||
""
|
||||
];
|
||||
|
||||
if (directories.length > 0) {
|
||||
lines.push("## Directories", "");
|
||||
for (const dir of directories) {
|
||||
lines.push(`- 📁 ${dir.name}/`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
lines.push("## Files", "");
|
||||
for (const file of files) {
|
||||
lines.push(`- 📄 ${file.name} (${formatFileSize(file.size)})`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
textContent = lines.join('\n');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: textContent }],
|
||||
structuredContent: output
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
let errorText: string;
|
||||
|
||||
if (error instanceof Error && 'code' in error) {
|
||||
const code = (error as { code: string }).code;
|
||||
switch (code) {
|
||||
case "ENOENT":
|
||||
errorText = `Error: Directory not found: ${params.path}`;
|
||||
break;
|
||||
case "EACCES":
|
||||
errorText = `Error: Permission denied to access directory: ${params.path}`;
|
||||
break;
|
||||
default:
|
||||
errorText = `Error: Failed to list directory: ${errorMessage}`;
|
||||
}
|
||||
} else {
|
||||
errorText = `Error: Failed to list directory: ${errorMessage}`;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: errorText }]
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { promises as fs } from "fs";
|
||||
import { stat } from "fs/promises";
|
||||
import { DEFAULT_ENCODING, CHARACTER_LIMIT } from "../constants.js";
|
||||
import type { ResponseFormat } from "../types.js";
|
||||
|
||||
const ReadFileInputSchema = z.object({
|
||||
path: z.string()
|
||||
.min(1, "Path is required")
|
||||
.describe("Absolute path to the file to read"),
|
||||
encoding: z.string()
|
||||
.default(DEFAULT_ENCODING)
|
||||
.describe("File encoding (default: utf-8)"),
|
||||
max_chars: z.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(CHARACTER_LIMIT)
|
||||
.optional()
|
||||
.describe("Maximum number of characters to return"),
|
||||
line_start: z.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Starting line number (1-based)"),
|
||||
line_end: z.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Ending line number (1-based, inclusive)"),
|
||||
response_format: z.enum(["markdown", "json"])
|
||||
.default("markdown")
|
||||
.describe("Output format: 'markdown' for human-readable or 'json' for machine-readable")
|
||||
}).strict();
|
||||
|
||||
type ReadFileInput = z.infer<typeof ReadFileInputSchema>;
|
||||
|
||||
export function registerReadFileTool(server: McpServer) {
|
||||
server.registerTool(
|
||||
"read_file",
|
||||
{
|
||||
title: "Read File",
|
||||
description: `Read the contents of a file from the filesystem.
|
||||
|
||||
This tool reads a file and returns its contents. For large files, use max_chars to limit the output, or use line_start and line_end to read specific line ranges.
|
||||
|
||||
Args:
|
||||
- path (string): Absolute path to the file to read
|
||||
- encoding (string): File encoding (default: utf-8). Common encodings: utf-8, ascii, base64
|
||||
- max_chars (number): Maximum number of characters to return (optional)
|
||||
- line_start (number): Starting line number, 1-based (optional)
|
||||
- line_end (number): Ending line number, 1-based (optional)
|
||||
- response_format ('markdown' | 'json'): Output format (default: 'markdown')
|
||||
|
||||
Returns:
|
||||
For JSON format: Structured data with schema:
|
||||
{
|
||||
"path": string, // Path to the file
|
||||
"size": number, // File size in bytes
|
||||
"lines": number, // Total number of lines
|
||||
"content": string, // File contents
|
||||
"truncated": boolean, // Whether content was truncated
|
||||
"partial_range": { // Present if reading partial file
|
||||
"line_start": number,
|
||||
"line_end": number
|
||||
}
|
||||
}
|
||||
|
||||
Examples:
|
||||
- Use when: "Read the contents of config.json" -> params with path="/path/to/config.json"
|
||||
- Use when: "Show first 100 lines of a large log file" -> params with path="/var/log/app.log", line_start=1, line_end=100
|
||||
- Use when: "Get last 500 characters of a file" -> params with path="/path/to/file.txt", max_chars=500
|
||||
|
||||
Error Handling:
|
||||
- Returns "Error: File not found" if the path does not exist (ENOENT)
|
||||
- Returns "Error: Permission denied" if the file cannot be read (EACCES)
|
||||
- Returns "Error: Path is a directory" if the path is a directory`,
|
||||
inputSchema: ReadFileInputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false
|
||||
}
|
||||
},
|
||||
async (params: ReadFileInput) => {
|
||||
try {
|
||||
// Check if path exists and get file stats
|
||||
const fileStats = await stat(params.path);
|
||||
|
||||
if (fileStats.isDirectory()) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Error: Path is a directory, not a file: ${params.path}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const contentBuffer = await fs.readFile(params.path);
|
||||
let content = contentBuffer.toString(params.encoding as BufferEncoding);
|
||||
|
||||
const totalLines = content.split('\n').length;
|
||||
let truncated = false;
|
||||
let partialRange: { line_start: number; line_end: number } | undefined;
|
||||
|
||||
// Handle line range filtering
|
||||
if (params.line_start !== undefined || params.line_end !== undefined) {
|
||||
const lines = content.split('\n');
|
||||
const start = (params.line_start || 1) - 1;
|
||||
const end = params.line_end ? params.line_end : lines.length;
|
||||
content = lines.slice(start, end).join('\n');
|
||||
partialRange = {
|
||||
line_start: start + 1,
|
||||
line_end: end
|
||||
};
|
||||
}
|
||||
|
||||
// Handle character limit
|
||||
if (params.max_chars && content.length > params.max_chars) {
|
||||
content = content.slice(0, params.max_chars);
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
const output = {
|
||||
path: params.path,
|
||||
size: fileStats.size,
|
||||
lines: totalLines,
|
||||
content,
|
||||
truncated,
|
||||
...(partialRange && { partial_range: partialRange })
|
||||
};
|
||||
|
||||
let textContent: string;
|
||||
if (params.response_format === "json") {
|
||||
textContent = JSON.stringify(output, null, 2);
|
||||
} else {
|
||||
const lines = [`# File: ${params.path}`, "",
|
||||
`Size: ${fileStats.size} bytes | Lines: ${totalLines}`, ""];
|
||||
if (truncated) {
|
||||
lines.push(`⚠️ Content truncated to ${params.max_chars} characters`);
|
||||
}
|
||||
if (partialRange) {
|
||||
lines.push(`📄 Showing lines ${partialRange.line_start}-${partialRange.line_end}`);
|
||||
}
|
||||
lines.push("", "```", content, "```");
|
||||
textContent = lines.join('\n');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: textContent }],
|
||||
structuredContent: output
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
let errorText: string;
|
||||
|
||||
if (error instanceof Error && 'code' in error) {
|
||||
const code = (error as { code: string }).code;
|
||||
switch (code) {
|
||||
case "ENOENT":
|
||||
errorText = `Error: File not found: ${params.path}`;
|
||||
break;
|
||||
case "EACCES":
|
||||
errorText = `Error: Permission denied to read file: ${params.path}`;
|
||||
break;
|
||||
default:
|
||||
errorText = `Error: Failed to read file: ${errorMessage}`;
|
||||
}
|
||||
} else {
|
||||
errorText = `Error: Failed to read file: ${errorMessage}`;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: errorText }]
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { promises as fs } from "fs";
|
||||
import { lstat } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { DEFAULT_FILE_LIMIT, MAX_DEPTH } from "../constants.js";
|
||||
import type { SearchResult } from "../types.js";
|
||||
|
||||
const SearchFilesInputSchema = z.object({
|
||||
path: z.string()
|
||||
.min(1, "Path is required")
|
||||
.describe("Absolute path to the directory to search in"),
|
||||
pattern: z.string()
|
||||
.min(1, "Search pattern is required")
|
||||
.describe("Glob pattern to match files (e.g., '*.ts', '**/*.json')"),
|
||||
max_results: z.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(DEFAULT_FILE_LIMIT)
|
||||
.default(50)
|
||||
.describe("Maximum number of results to return"),
|
||||
include_content: z.boolean()
|
||||
.default(false)
|
||||
.describe("Whether to include file content in results"),
|
||||
response_format: z.enum(["markdown", "json"])
|
||||
.default("markdown")
|
||||
.describe("Output format: 'markdown' for human-readable or 'json' for machine-readable")
|
||||
}).strict();
|
||||
|
||||
type SearchFilesInput = z.infer<typeof SearchFilesInputSchema>;
|
||||
|
||||
function matchPattern(filename: string, pattern: string): boolean {
|
||||
// Simple glob matching - convert glob pattern to regex
|
||||
const regexPattern = pattern
|
||||
.replace(/\./g, '\\.')
|
||||
.replace(/\*/g, '.*')
|
||||
.replace(/\?/g, '.');
|
||||
const regex = new RegExp(`^${regexPattern}$`, 'i');
|
||||
return regex.test(filename);
|
||||
}
|
||||
|
||||
async function* walkDirectory(
|
||||
dir: string,
|
||||
maxDepth: number,
|
||||
currentDepth: number = 0
|
||||
): AsyncGenerator<string> {
|
||||
if (currentDepth >= maxDepth) return;
|
||||
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
yield fullPath;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
yield* walkDirectory(fullPath, maxDepth, currentDepth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSearchFilesTool(server: McpServer) {
|
||||
server.registerTool(
|
||||
"search_files",
|
||||
{
|
||||
title: "Search Files",
|
||||
description: `Search for files in a directory using glob patterns.
|
||||
|
||||
This tool recursively searches a directory and returns files matching the specified pattern. It supports basic glob patterns like *.ts, **/*.json, etc.
|
||||
|
||||
Args:
|
||||
- path (string): Absolute path to the directory to search in
|
||||
- pattern (string): Glob pattern to match files (e.g., '*.ts', '**/*.json', 'src/**/*.ts')
|
||||
- max_results (number): Maximum number of results to return (default: 50, max: 100)
|
||||
- include_content (boolean): Whether to include file content in results (default: false)
|
||||
- response_format ('markdown' | 'json'): Output format (default: 'markdown')
|
||||
|
||||
Returns:
|
||||
For JSON format: Structured data with schema:
|
||||
{
|
||||
"search_path": string, // Path that was searched
|
||||
"pattern": string, // Pattern used
|
||||
"total_found": number, // Total files matching the pattern
|
||||
"results_returned": number,// Number of results in this response
|
||||
"truncated": boolean, // Whether results were truncated
|
||||
"files": [
|
||||
{
|
||||
"name": string, // File name
|
||||
"path": string, // Absolute path
|
||||
"isDirectory": boolean,
|
||||
"size": number, // File size in bytes
|
||||
"modified": string, // ISO date string
|
||||
"content": string // Present if include_content is true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Examples:
|
||||
- Use when: "Find all TypeScript files in the project" -> params with path="/path/to/project", pattern="*.ts"
|
||||
- Use when: "Find all JSON config files recursively" -> params with path="/path/to/project", pattern="**/*.json"
|
||||
- Use when: "List all Markdown files with their contents" -> params with path="/path/to/docs", pattern="*.md", include_content=true
|
||||
- Don't use when: You need to read a specific file (use read_file instead)
|
||||
- Don't use when: You need to list all files without filtering (use list_directory instead)
|
||||
|
||||
Error Handling:
|
||||
- Returns "Error: Directory not found" if the search path does not exist (ENOENT)
|
||||
- Returns "Error: Path is not a directory" if the path is a file
|
||||
- Returns "Error: Permission denied" if directories cannot be accessed (EACCES)`,
|
||||
inputSchema: SearchFilesInputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false
|
||||
}
|
||||
},
|
||||
async (params: SearchFilesInput) => {
|
||||
try {
|
||||
// Check if path exists and is a directory
|
||||
const pathStats = await lstat(params.path);
|
||||
|
||||
if (!pathStats.isDirectory()) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Error: Path is not a directory: ${params.path}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
const results: SearchResult[] = [];
|
||||
let totalFound = 0;
|
||||
|
||||
// Search directory
|
||||
for await (const filePath of walkDirectory(params.path, MAX_DEPTH)) {
|
||||
const filename = filePath.split(/[/\\]/).pop() || filePath;
|
||||
|
||||
if (matchPattern(filename, params.pattern)) {
|
||||
totalFound++;
|
||||
|
||||
if (results.length < params.max_results) {
|
||||
const fileStats = await lstat(filePath);
|
||||
|
||||
const result: SearchResult = {
|
||||
name: filename,
|
||||
path: filePath,
|
||||
isDirectory: fileStats.isDirectory(),
|
||||
size: fileStats.size,
|
||||
modified: fileStats.mtime.toISOString()
|
||||
};
|
||||
|
||||
if (params.include_content && !fileStats.isDirectory()) {
|
||||
try {
|
||||
const contentBuffer = await fs.readFile(filePath);
|
||||
result.content = contentBuffer.toString('utf-8');
|
||||
} catch {
|
||||
result.content = '[Unable to read file content]';
|
||||
}
|
||||
}
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const output = {
|
||||
search_path: params.path,
|
||||
pattern: params.pattern,
|
||||
total_found: totalFound,
|
||||
results_returned: results.length,
|
||||
truncated: totalFound > params.max_results,
|
||||
files: results
|
||||
};
|
||||
|
||||
let textContent: string;
|
||||
if (params.response_format === "json") {
|
||||
textContent = JSON.stringify(output, null, 2);
|
||||
} else {
|
||||
const lines: string[] = [
|
||||
`# Search Results: ${params.pattern}`,
|
||||
`Search path: ${params.path}`,
|
||||
`Found ${totalFound} file(s), showing ${results.length}`,
|
||||
""
|
||||
];
|
||||
|
||||
if (output.truncated) {
|
||||
lines.push(`⚠️ Results truncated. Use max_results=${params.max_results} to see more.`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
for (const file of results) {
|
||||
const type = file.isDirectory ? "📁" : "📄";
|
||||
lines.push(`${type} ${file.path}`);
|
||||
|
||||
if (params.include_content && file.content) {
|
||||
lines.push("```");
|
||||
const preview = file.content.slice(0, 500);
|
||||
lines.push(file.content.length > 500 ? preview + "..." : preview);
|
||||
lines.push("```");
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
textContent = lines.join('\n');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: textContent }],
|
||||
structuredContent: output
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
let errorText: string;
|
||||
|
||||
if (error instanceof Error && 'code' in error) {
|
||||
const code = (error as { code: string }).code;
|
||||
switch (code) {
|
||||
case "ENOENT":
|
||||
errorText = `Error: Directory not found: ${params.path}`;
|
||||
break;
|
||||
case "EACCES":
|
||||
errorText = `Error: Permission denied to access directory: ${params.path}`;
|
||||
break;
|
||||
default:
|
||||
errorText = `Error: Failed to search files: ${errorMessage}`;
|
||||
}
|
||||
} else {
|
||||
errorText = `Error: Failed to search files: ${errorMessage}`;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: errorText }]
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* Type definitions for the FileSystem MCP Server.
|
||||
*/
|
||||
|
||||
export interface FileInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
size: number;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
export interface DirectoryEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
size: number;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
size: number;
|
||||
modified: string;
|
||||
matchLine?: number;
|
||||
matchContent?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export enum ResponseFormat {
|
||||
MARKDOWN = "markdown",
|
||||
JSON = "json"
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Reference in New Issue