【203篇系列】042 AndyBotFS

这里尝试构建文件操作的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

结果如下:

以下是你最近下载的文件:

  1. 文件名 :小学一年级数学全册知识点详解(共37册)(1).pdf
    • 路径:/YourPath/Downloads/小学一年级数学全册知识点详解(共37册)(1).pdf
    • 文件类型:file
    • 大小:3954409
    • 修改时间:2023-01-12 16:01:53
  2. 文件名 :监控风扇测试预测试结果_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 模拟真实场景,实现了单轮的大模型工具问答
相关推荐
开开心心就好2 小时前
免费音频转文字工具,绿色版离线多模型可用
人工智能·windows·计算机视觉·计算机外设·ocr·excel·语音识别
菜鸟小芯2 小时前
从 GLM-5 提示到思考:我用深度思考解决了 “50 米去超市” 的问题
人工智能
沪漂阿龙2 小时前
AI幻觉问题及缓解策略
人工智能
浩瀚之水_csdn2 小时前
avcodec_parameters_copy详解
linux·人工智能·ffmpeg
石去皿2 小时前
AI命名实体识别常见面试篇
人工智能·面试·职场和发展
沪漂阿龙2 小时前
从Chatbot到Agent:AI如何从“能说会道”进化为“能干实事”
人工智能
sanshanjianke2 小时前
AI辅助网文创作理论研究笔记(一):叙事模型的构建
人工智能·语言模型·ai写作
TImCheng06092 小时前
能力模型构建:AI产品经理所需技术、伦理与商业知识的比例与深度
人工智能
甲枫叶2 小时前
【claude产品经理系列11】实现后端接口——数据在背后如何流动
java·数据库·人工智能·产品经理·ai编程·visual studio code