这里尝试构建文件操作的MCP,调试的差不多了就把这类工具都放到github上。
这几天稍微摸索了一下,有些意料之中,有些是我以前不知道的,总体上还是很有意思的。
首先我想回到AI助手一个比较基础的问题:安全性。
之前用openclaw的时候,我单独买了一台机器,因为担心它会破坏我生产电脑的环境,也担心它会不受控的将我的敏感信息传出去。到现在我也不是那么确定,因为openclaw虽然在很多边缘工具上是采用npm方式安装的,但文件系统、命令行工具可能也是在其内部的。其他的一些工具,比如cline, cursor 都是如此,而恰恰文件系统和命令行是最底层的操作,一旦使用多了,一定会开放几乎所有的文件访问权限。然后通过命令行其实就可以模糊搜索到所有重要特征的文件,理论上是存在泄露的可能的。
要放心的话,首先就得从最基础的文件操作开始透明。除了代码透明(所有人可见),还要可阅读。node的代码太长了,而且阅读性也没有python好,所以我才想把这个MCP(AndyBotFS)做好还是真的有意义的。
BTW, 之后的MCP有点类似微服务,采取两级划分应该是有必要的,否则(假设只有一个大的扁平分级)每次吞吐太多的内容非常不环保。
先进入正题吧,我先让大模型按照这个规范开发MCP。我现在要尽量约束我自己,尽量做到代码参与率低于5%,我主要应该是设计和验收。
bash
# st-0012
说明:规范通用mcp服务开发
• 0 规范编号:st-0012
• 1 在指定了项目根目录后,以下文件均在项目根目录下执行
• 2 server.py , 使用FastMCP开发,采用mcp.tool装饰器,每个函数采用docstring,函数均用关键字参数
• 3 .env 文件,将重要的环境变量,如api-key等存放在此
• 4 base_config.py 存放各类基础配置信息,采用Naive对象来挂接。示例```class Naive:
def init(self,data :dict = None):
# 如果传了 dict,就先更新
if data:
for k, v in data.items():
setattr(self, k, v)
def dict(self):
return self.dict
base_config = Naive()
base_config.chank_front_url = 'http://localhost:24622/'
```
• 5 funcs.py , 相关函数放在这个脚本下
• 6 setup_and_test.sh ,使用uv来构建运行环境,对应配置应该在 pyproject.toml下。测试时应当包含此项(环境搭建测试),uv的安装使用国内镜像源(如清华镜像源)
• 7 test_client.py, 测试客户端,用于验证mcp的各项功能。使用```from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client```
•
• 8 cline-config.json ,按照cline规范的mcp配置文件
• 9 README.md 总结项目结构,并将项目下的各部分予以说明
服务内容如下:
python
"""
st-0012: MCP服务 - 文件操作工具集
使用 FastMCP 开发,提供安全、可靠的文件操作功能
"""
import logging
from fastmcp import FastMCP
from base_config import base_config
from funcs import (
file_read,
file_write,
file_edit,
file_delete,
file_copy,
file_move,
file_info,
file_hash,
dir_list,
dir_copy,
dir_create,
dir_delete,
search_content
)
# 配置日志
logging.basicConfig(
level=getattr(logging, base_config.log_level),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(base_config.service_name)
# 创建 MCP 服务
mcp = FastMCP(base_config.service_name)
# ============== 文件操作工具 ==============
@mcp.tool()
def read_file(
path: str,
encoding: str = "utf-8",
start_line: int = None,
end_line: int = None
) -> dict:
"""
读取文件内容
Args:
path: 文件路径
encoding: 文件编码,默认utf-8
start_line: 起始行号(可选,从1开始)
end_line: 结束行号(可选)
Returns:
包含文件内容和元信息的字典
"""
logger.info(f"读取文件: {path}")
return file_read(
path=path,
encoding=encoding,
start_line=start_line,
end_line=end_line
)
@mcp.tool()
def write_file(
path: str,
content: str,
encoding: str = "utf-8",
mode: str = "write"
) -> dict:
"""
写入文件内容
Args:
path: 文件路径
content: 要写入的内容
encoding: 文件编码,默认utf-8
mode: 写入模式,'write'覆盖写入,'append'追加写入
Returns:
操作结果
"""
logger.info(f"写入文件: {path}, 模式: {mode}")
return file_write(
path=path,
content=content,
encoding=encoding,
mode=mode
)
@mcp.tool()
def edit_file(
path: str,
old_str: str = None,
new_str: str = None,
start_line: int = None,
end_line: int = None,
content: str = None,
encoding: str = "utf-8"
) -> dict:
"""
编辑文件内容
三种模式:
1. 替换模式:提供 old_str 和 new_str,替换所有匹配的文本
2. 行范围替换:提供 start_line、end_line 和 content,替换指定行范围
3. 插入模式:提供 start_line 和 content(无 end_line),在指定行前插入
Args:
path: 文件路径
old_str: 要替换的文本
new_str: 替换后的文本
start_line: 起始行号(从1开始)
end_line: 结束行号
content: 新内容(用于行范围替换或插入)
encoding: 文件编码,默认utf-8
Returns:
操作结果
"""
logger.info(f"编辑文件: {path}")
return file_edit(
path=path,
old_str=old_str,
new_str=new_str,
start_line=start_line,
end_line=end_line,
content=content,
encoding=encoding
)
@mcp.tool()
def delete_file(
path: str
) -> dict:
"""
删除文件
Args:
path: 文件路径
Returns:
操作结果
"""
logger.info(f"删除文件: {path}")
return file_delete(path=path)
@mcp.tool()
def copy_file(
src: str,
dst: str
) -> dict:
"""
复制文件
Args:
src: 源文件路径
dst: 目标文件路径
Returns:
操作结果
"""
logger.info(f"复制文件: {src} -> {dst}")
return file_copy(src=src, dst=dst)
@mcp.tool()
def move_file(
src: str,
dst: str
) -> dict:
"""
移动文件
Args:
src: 源文件路径
dst: 目标文件路径
Returns:
操作结果
"""
logger.info(f"移动文件: {src} -> {dst}")
return file_move(src=src, dst=dst)
@mcp.tool()
def get_file_info(
path: str
) -> dict:
"""
获取文件详细信息
Args:
path: 文件路径
Returns:
文件信息字典
"""
logger.info(f"获取文件信息: {path}")
return file_info(path=path)
@mcp.tool()
def get_file_hash(
path: str,
algorithm: str = "sha256"
) -> dict:
"""
计算文件哈希值
Args:
path: 文件路径
algorithm: 哈希算法,支持 md5, sha1, sha256, sha512
Returns:
包含哈希值的字典
"""
logger.info(f"计算文件哈希: {path}, 算法: {algorithm}")
return file_hash(path=path, algorithm=algorithm)
# ============== 目录操作工具 ==============
@mcp.tool()
def list_directory(
path: str = ".",
pattern: str = "*",
recursive: bool = False,
min_size: int = None,
max_size: int = None,
min_mtime: str = None,
max_mtime: str = None
) -> dict:
"""
列出目录内容
Args:
path: 目录路径,默认当前目录
pattern: 文件匹配模式,默认*
recursive: 是否递归列出,默认False
min_size: 最小文件大小(字节),仅对文件有效
max_size: 最大文件大小(字节),仅对文件有效
min_mtime: 最小修改时间(ISO格式,如 2024-01-01 或 2024-01-01T00:00:00)
max_mtime: 最大修改时间(ISO格式)
Returns:
目录内容列表
"""
logger.info(f"列出目录: {path}, 模式: {pattern}, 递归: {recursive}")
return dir_list(
path=path,
pattern=pattern,
recursive=recursive,
min_size=min_size,
max_size=max_size,
min_mtime=min_mtime,
max_mtime=max_mtime
)
@mcp.tool()
def copy_directory(
src: str,
dst: str,
exclude: str = None,
delete: bool = False,
update: bool = True,
preserve: bool = True,
dry_run: bool = False
) -> dict:
"""
复制目录(类似 rsync 方式)
Args:
src: 源目录路径
dst: 目标目录路径
exclude: 排除模式(逗号分隔,如 "*.pyc,*.log,.git")
delete: 是否删除目标端多余的文件(类似 rsync --delete)
update: 是否只复制更新的文件(比较修改时间和大小),默认True
preserve: 是否保留文件属性(权限、时间戳),默认True
dry_run: 是否只预览不执行,默认False
Returns:
操作结果和统计信息
"""
logger.info(f"复制目录: {src} -> {dst}, delete={delete}, update={update}")
return dir_copy(
src=src,
dst=dst,
exclude=exclude,
delete=delete,
update=update,
preserve=preserve,
dry_run=dry_run
)
@mcp.tool()
def create_directory(
path: str
) -> dict:
"""
创建目录
Args:
path: 目录路径
Returns:
操作结果
"""
logger.info(f"创建目录: {path}")
return dir_create(path=path)
@mcp.tool()
def delete_directory(
path: str,
recursive: bool = False
) -> dict:
"""
删除目录
Args:
path: 目录路径
recursive: 是否递归删除,默认False
Returns:
操作结果
"""
logger.info(f"删除目录: {path}, 递归: {recursive}")
return dir_delete(path=path, recursive=recursive)
# ============== 搜索工具 ==============
@mcp.tool()
def search_in_files(
path: str,
pattern: str,
file_pattern: str = "*",
recursive: bool = True
) -> dict:
"""
在文件中搜索内容
Args:
path: 搜索根目录
pattern: 搜索内容(支持正则表达式)
file_pattern: 文件匹配模式,默认*
recursive: 是否递归搜索,默认True
Returns:
搜索结果
"""
logger.info(f"搜索文件内容: {path}, 模式: {pattern}")
return search_content(
path=path,
pattern=pattern,
file_pattern=file_pattern,
recursive=recursive
)
# ============== 资源 ==============
@mcp.resource("config://settings")
def get_config() -> str:
"""获取当前配置信息"""
import json
config = {
"service_name": base_config.service_name,
"version": base_config.service_version,
"log_level": base_config.log_level,
"allowed_root": base_config.allowed_root or "未限制",
"max_file_size_mb": base_config.max_file_size_mb,
"allow_delete": base_config.allow_delete,
"allow_mkdir": base_config.allow_mkdir
}
return json.dumps(config, ensure_ascii=False, indent=2)
# ============== 启动服务 ==============
if __name__ == "__main__":
logger.info(f"启动 {base_config.service_name} v{base_config.service_version}")
mcp.run()
总体上来说,一个二级mcp大概也就是这样一个复杂度,一些更细的、更复杂的功能可以放在函数里,甚至放在接口函数里。
怎么用
1 环境
项目使用uv来搭建虚拟环境
2 配置
然后在使用时,使用这样的方法进行配置,以下是cline的配置格式
json
{
"mcpServers": {
"andybot_fs": {
"disabled": false,
"timeout": 60,
"type": "stdio",
"command": "YOURPATHopt/anaconda3/envs/mcp/bin/uv",
"args": [
"--directory",
"YOURPATH/andybot_fs",
"run",
"server.py"
]
}
}
}
3 使用
在工具中进行问答,就会自然唤起mcp进行回答。
但是上面这种使用方法,是使用了一些工具内置的mcp客户端进行调用的,从了解原理,以及后续扩展的角度上还不够直观。
既然有了mcp服务,那就需要mcp 客户端来进行调用。下面是一个更贴近脑子中逻辑场景的调用:
3.1 调用大模型决策
python
from openai import OpenAI
client = OpenAI(
api_key=api_key,
base_url="https://ark.cn-beijing.volces.com/api/v3"
)
async def call_doubao(
messages,
tools=None,
temperature=0,
model=model
):
response = client.chat.completions.create(
model=model,
messages=messages,
tools=tools, # 关键
tool_choice="auto", # 允许模型决定是否调用工具
temperature=temperature
)
return response
3.2 mcp客户端
- 1 查看工具列表
通过调用 get_tools 来返回mcp下所有的工具
python
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
import asyncio
async def get_tools():
params = StdioServerParameters(
command="YOURPATH/opt/anaconda3/envs/mcp/bin/uv",
args=["--directory", "YOURPATH/pre_research/chank_mcp/andybot_fs", "run", "server.py"]
)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# ✅ 协议方法 - 直接调用
tools = await session.list_tools()
return tools
await get_tools()
ListToolsResult(meta=None, nextCursor=None, tools=[Tool(name='read_file', title=None, description='读取文件内容\n\nArgs:\n path: 文件路径\n encoding: 文件编码,默认utf-8\n start_line: 起始行号(可选,从1开始)\n end_line: 结束行号(可选)\n \nReturns:\n 包含文件内容和元信息的字典', inputSchema={'properties': {'path': {'type': 'string'}, 'encoding': {'default': 'utf-8', 'type': 'string'}, 'start_line': {'anyOf': [{'type': 'integer'}, {'type': 'null'}], 'default': None},...
- 2 调用mcp工具
通过 call_mcp 来调用工具
python
async def call_mcp(tool_name, arguments):
params = StdioServerParameters(
command="YOURPATH/opt/anaconda3/envs/mcp/bin/uv",
args=[
"--directory",
"YOURPATH/pre_research/chank_mcp/andybot_fs",
"run",
"server.py"
]
)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize() # ← 必须先初始化!
# result = await session.call_tool(tool_name, arguments)
# return result
result = await session.call_tool(
tool_name,
arguments
)
if result.isError:
raise RuntimeError(result.content)
return result.structuredContent
整体流程:
- 1 动态载入mcp信息
python
tools = await get_tools()
TOOLS_SCHEMA = []
for t in tools.tools:
TOOLS_SCHEMA.append({
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": t.inputSchema
}
})
In [47]: TOOLS_SCHEMA[:3]
Out[47]:
[{'type': 'function',
'function': {'name': 'read_file',
'description': '读取文件内容\n\nArgs:\n path: 文件路径\n encoding: 文件编码,默认utf-8\n start_line: 起始行号(可选,从1开始)\n end_line: 结束行号(可选)\n \nReturns:\n 包含文件内容和元信息的字典',
'parameters': {'properties': {'path': {'type': 'string'},
'encoding': {'default': 'utf-8', 'type': 'string'},
'start_line': {'anyOf': [{'type': 'integer'}, {'type': 'null'}],
'default': None},
'end_line': {'anyOf': [{'type': 'integer'}, {'type': 'null'}],
'default': None}},
'required': ['path'],
'type': 'object'}}},
{'type': 'function',
'function': {'name': 'write_file',
'description': "写入文件内容\n\nArgs:\n path: 文件路径\n content: 要写入的内容\n encoding: 文件编码,默认utf-8\n mode: 写入模式,'write'覆盖写入,'append'追加写入\n \nReturns:\n 操作结果",
'parameters': {'properties': {'path': {'type': 'string'},
'content': {'type': 'string'},
'encoding': {'default': 'utf-8', 'type': 'string'},
'mode': {'default': 'write', 'type': 'string'}},
'required': ['path', 'content'],
'type': 'object'}}},
...
- 2 用户发起提问,大模型判断是否需要工具
python
messages = [
{
"role": "system",
"content": "你是一个可以调用工具的智能助手。"
},
{
"role": "user",
"content": "列出我最近的下载文件"
}
]
response = await call_doubao(messages, tools=TOOLS_SCHEMA)
message = response.choices[0].message
ChatCompletionMessage(content='', refusal=None, role='assistant', annotations=None, audio=None, function_call=None, tool_calls=[ChatCompletionMessageFunctionToolCall(id='call_r5h6w6a4lqexwrrjyn9f2hq7', function=Function(arguments=' {"path": "~/Downloads", "min_mtime": "now", "pattern": "*"}', name='list_directory'), type='function')])
如果需要调用工具
python
tool_call = message.tool_calls[0]
tool_name = tool_call.function.name
arguments = json.loads(tool_call.function.arguments)
In [53]: tool_name
Out[53]: 'list_directory'
In [54]: arguments
Out[54]: {'path': '~/Downloads', 'min_mtime': 'now', 'pattern': '*'}
获取调用工具之后的结果
python
tool_result = await call_mcp(tool_name, arguments)
{'success': True,
'path': 'YourPath/Downloads',
'pattern': '*',
'recursive': False,
'filters': {'min_size': None,
'max_size': None,
'min_mtime': 'now',
'max_mtime': None},
'pagination': {'page': 1,
'page_size': 10,
'total_count': 3052,
'total_pages': 306,
'has_next': True,
'has_prev': False},
'count': 10,
'items': [{'name': '小学一年级数学全套练习题(共37套)(1).pdf',
'path': 'YourPath/Downloads/小学一年级数学全套练习题(共37套)(1).pdf',
'type': 'file',
'size': 3954409,
...
将工具结果交给大模型返回
python
messages.append(message) # assistant 的 tool_call
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(tool_result)
})
final_response = await call_doubao(messages)
final_answer = final_response.choices[0].message.content
结果如下:
以下是你最近下载的文件:
- 文件名 :小学一年级数学全册知识点详解(共37册)(1).pdf
- 路径:/YourPath/Downloads/小学一年级数学全册知识点详解(共37册)(1).pdf
- 文件类型:file
- 大小:3954409
- 修改时间:2023-01-12 16:01:53
- 文件名 :监控风扇测试预测试结果_0306_result.xlsx
- 路径:/YourPath/Downloads/监控风扇测试预测试结果_0306_result.xlsx
- 文件类型:file
- 大小:199266
- 修改时间:2024-03-06 14:11:13
整体上,把这个单次的对话进行不定次循环的拓展(也就是做自己的执行引擎),那么最终就能实现类似openclaw或者opencode的效用效果了。
其实做到这里的时候,我一直觉得这个很像以前说的function calling,而我最初是因为要做一个类似openclaw的bot才搞的(skills),这个容我后续再想想。不过用mcp应该是对的,这种模式让函数的定义变得很规范,而且权限也可以通过类似微服务的方式进行限制。
过程中,每次调用大模型都会唤起服务(这也是mcp的默认模式),所以用uv是一种比较轻便的方式,未来如果要常驻生产的话,可能也需要做一些优化。

总结
- 1 使用大模型开放mcp也是比较方便的,不需要写代码
- 2 使用本地方式调用了mcp
- 3 模拟真实场景,实现了单轮的大模型工具问答