[agent] MCP and SKILL

模型上下文协议

Ref: MCP实战指南,mcp视频教程,2小时学透mcp

awesome-mcp-servers: https://github.com/punkpeye/awesome-mcp-servers

stdio (default) and SSE (Server-Sent Events)

 

image

 

MCP的c/s架构

image

 

手动开发一个MCP项目。

import asyncio
import json
import os
import re
from contextlib import AsyncExitStack
from datetime import datetime
from typing import Any, Dict, List, Optional

from dotenv import load_dotenv
from openai import OpenAI

# ===== MCP 相关导入(重点) =====
# 这几行就是“本文件为什么是 MCP client”的最核心证据:
# - ClientSession: 表示“和 MCP server 的会话”
# - StdioServerParameters: 告诉客户端“如何启动 server”
# - stdio_client: 通过标准输入/输出(stdin/stdout)和 server 通信
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

load_dotenv()


class TeachingMCPClient:
    """
    这是一个“教学版 MCP 客户端”。

    你可以把它想成 3 层:

    第 1 层:MCP 标准层
        connect_to_server()
        get_available_tools()
        execute_tool_plan() 里的 session.call_tool(...)

        这一层负责:
        - 连接 MCP server
        - 初始化会话
        - 发现 server 暴露了哪些工具
        - 真正调用工具

    第 2 层:LLM 规划层(不是 MCP 标准,是本项目自己加的)
        plan_tool_usage()

        这一层负责:
        - 把“有哪些工具”告诉大模型
        - 让大模型先输出一个“工具调用计划”

    第 3 层:业务辅助层(不是 MCP 标准,是为了 demo 更完整)
        build_report_filename()
        save_final_output()
        process_query()

        这一层负责:
        - 生成报告文件名
        - 保存最终对话记录
        - 串联整个流程

    一句话总结:
        用户提问
          -> 连接 MCP server
          -> 查询有哪些工具
          -> 让 LLM 先规划怎么用工具
          -> 通过 MCP 调工具
          -> 再让 LLM 总结结果
    """

    def __init__(self) -> None:
        """
        初始化客户端本身。

        这里要准备两类“客户端”:

        1. LLM 客户端(OpenAI)
           用来和大模型对话,让模型做规划、做总结。

        2. MCP 会话对象(self.session)
           用来和 MCP server 对话,查工具、调工具。
        """

# 这是个 统一资源管理器
self.exit_stack = AsyncExitStack() # ===== 读取大模型配置 ===== self.api_key = os.getenv("DASHSCOPE_API_KEY") self.base_url = os.getenv("BASE_URL") self.model = os.getenv("MODEL") if not self.api_key: raise ValueError("❌ 未找到 DASHSCOPE_API_KEY,请在 .env 中配置") # 这是“大模型客户端”,不是 MCP 客户端。 self.llm_client = OpenAI(api_key=self.api_key, base_url=self.base_url) # 这是“MCP 会话对象”,连接 server 后才会有值。 self.session: Optional[ClientSession] = None # --------------------------------------------------------------------- # 第 1 部分:MCP 标准动作 # --------------------------------------------------------------------- async def connect_to_server(self, server_script_path: str) -> None: """ 【MCP 标准动作】连接并初始化 MCP server。 这是最值得你认识的函数之一。 它体现 MCP 的地方有 4 步: 1. 定义 server 启动方式(StdioServerParameters) 2. 建立 stdio 通信(stdio_client) 3. 创建 MCP 会话(ClientSession) 4. 初始化会话(session.initialize) """ is_python = server_script_path.endswith(".py") is_js = server_script_path.endswith(".js") if not (is_python or is_js): raise ValueError("服务器脚本必须是 .py 或 .js 文件") # 如果是 Python server,就用 python 启动;如果是 JS server,就用 node 启动。 command = "python" if is_python else "node" # 【MCP 标准动作】告诉客户端: # “要启动哪个 server,它用什么命令启动,它通过什么方式通信。” server_params = StdioServerParameters( command=command, args=[server_script_path], env=None, ) # 【MCP 标准动作】通过 stdin/stdout 与 MCP server 建立通信。 stdio_transport = await self.exit_stack.enter_async_context(
# 创建一个通过 stdin/stdout 去连接 MCP server 的客户端通道 stdio_client(server_params) ) self.stdio, self.write
= stdio_transport # 【MCP 标准动作】创建 MCP 会话。 self.session = await self.exit_stack.enter_async_context(
# (接收端,发送端) ClientSession(self.stdio, self.write) )
# 【MCP 标准动作】初始化握手。 await self.session.initialize() # 连接成功后,顺手展示一下 server 暴露了哪些工具。 tools = await self.get_available_tools()
print("\n✅ 已连接 MCP server") print("🧰 可用工具:", [tool["function"]["name"] for tool in tools]) async def get_available_tools(self) -> List[Dict[str, Any]]: """ 【MCP 标准动作】读取 server 当前暴露的工具列表。(它不是 MCP 新发明的标准函数,而是“项目级 helper”) 注意: - 这一步是 MCP 的关键价值之一。 - client 不需要把工具名字硬编码死。 - 它可以在运行时直接问 server:“你有哪些工具?” 返回值为什么不是原始 tools,而是 function 风格的 dict? 因为后面我们要把工具定义直接交给 LLM 看。 """ if self.session is None: raise RuntimeError("MCP session 尚未建立,请先 connect_to_server()") # 【MCP 标准动作】从 server 获取工具清单。 response = await self.session.list_tools() # 【项目自定义逻辑】 # 这里把 MCP tools 转成更适合大模型理解的结构。 available_tools = [ { "type": "function", "function": { "name": tool.name, "description": tool.description, "input_schema": tool.inputSchema, }, } for tool in response.tools ] return available_tools async def execute_tool_plan( self, tool_plan: List[Dict[str, Any]], report_filename: str, report_path: str, ) -> Dict[str, str]: """ 【MCP 标准动作 + 项目自定义逻辑】执行工具调用计划。 -------------------> 从另一个角度,这就是skills的起源!
        先说什么是标准:
            session.call_tool(tool_name, tool_args)
            这句就是 MCP 的真正“调工具”。

        再说什么是自定义:
            - 用 {{工具名}} 引用上一步结果
            - 自动补 filename 参数
            - 自动补附件文件名
          这些都不是 MCP 标准,而是本 demo 的工作流写法。
        """
        if self.session is None:
            raise RuntimeError("MCP session 尚未建立,请先 connect_to_server()")

        tool_outputs: Dict[str, str] = {}

        for step_index, step in enumerate(tool_plan, start=1):
            tool_name = step.get("name")
            tool_args = step.get("arguments", {}).copy()

            print(f"\n--- 第 {step_index} 步:准备调用工具 {tool_name} ---")
            print("原始参数:", tool_args)

            # 【项目自定义逻辑】
            # 如果某个参数写成 {{search_google_news}},
            # 就表示“把 search_google_news 的输出结果塞到这里”。
            for key, value in tool_args.items():
                if isinstance(value, str) and value.startswith("{{") and value.endswith("}}"):
                    ref_tool_name = value.strip("{} ")
                    tool_args[key] = tool_outputs.get(ref_tool_name, value)

            # 【项目自定义逻辑】统一补一些默认参数。
            if tool_name == "analyze_sentiment" and "filename" not in tool_args:
                tool_args["filename"] = report_filename

            # 注意:原始 demo 这里补的是 attachment_path,
            # 但 server.py 的 send_email_with_attachment 实际需要的是 filename。
            # 这里已经修正为 filename。
            if tool_name == "send_email_with_attachment" and "filename" not in tool_args:
                tool_args["filename"] = report_filename

            print("解析后的参数:", tool_args)

            # 【MCP 标准动作】真正调用 server 上的工具。
            result = await self.session.call_tool(tool_name, tool_args)

            # 这里假设工具返回的主要文本在 content[0].text 中。
            tool_text = result.content[0].text
            tool_outputs[tool_name] = tool_text

            print(f"工具 {tool_name} 执行完成")
            print(f"输出预览: {tool_text[:200]}{'...' if len(tool_text) > 200 else ''}")

        return tool_outputs

    # ---------------------------------------------------------------------
    # 第 2 部分:LLM 规划层(不是 MCP 标准)
    # ---------------------------------------------------------------------

    async def plan_tool_usage(
        self,
        user_query: str,
        available_tools: List[Dict[str, Any]],
    ) -> List[Dict[str, Any]]:
        """
        【不是 MCP 标准】让大模型先规划“应该按什么顺序使用工具”。

        这一步非常重要,但请记住:
            它是“agent 工作流逻辑”,不是 MCP 协议本身。

        MCP 只负责:
            - server 有哪些工具
            - client 如何调用工具

        而这里负责的是:
            - 让 LLM 当一个“规划师”
            - 先输出 JSON 形式的多步计划
        """
        tool_list_text = "\n".join(
            [
                f"- {tool['function']['name']}: {tool['function']['description']}"
                for tool in available_tools
            ]
        )

        system_prompt = {
            "role": "system",
            "content": (
                "你是一个任务规划助手。用户会给出一句自然语言请求。\n"
                "你只能从以下工具中选择(严格使用工具名称):\n"
                f"{tool_list_text}\n\n"
                "请返回 JSON 数组。\n"
                "数组中的每个元素都必须是:\n"
                "{\"name\": 工具名, \"arguments\": {参数字典}}\n\n"
                "如果后一步要使用前一步的输出,可以写成 {{上一步工具名}}。\n"
                "不要返回自然语言解释,不要使用未列出的工具名。"
            ),
        }

        planning_messages = [
            system_prompt,
            {"role": "user", "content": user_query},
        ]

        response = self.llm_client.chat.completions.create(
            model=self.model,
            messages=planning_messages,
            tools=available_tools,
            tool_choice="none",  # 明确要求:只做规划,不直接调工具
        )

        content = (response.choices[0].message.content or "").strip()

        # 兼容模型可能返回 ```json ... ``` 的情况。
        match = re.search(r"```(?:json)?\s*([\s\S]+?)\s*```", content)
        json_text = match.group(1) if match else content

        try:
            plan = json.loads(json_text)
            if isinstance(plan, list):
                return plan
            return []
        except Exception as exc:
            print(f"❌ 规划结果不是合法 JSON: {exc}")
            print("模型原始输出:")
            print(content)
            return []

    # ---------------------------------------------------------------------
    # 第 3 部分:业务辅助层(不是 MCP 标准)
    # ---------------------------------------------------------------------

    def build_report_filename(self, user_query: str) -> Dict[str, str]:
        """
        【不是 MCP 标准】根据用户问题,提前生成报告文件名和路径。

        为什么要提前生成?
        因为后面的多个工具可能都要共用同一个文件名。
        提前统一好,整个工作流会更稳定。
        """
        keyword_match = re.search(r"(关于|分析|查询|搜索|查看)([^的\s,。、?\n]+)", user_query)
        keyword = keyword_match.group(2) if keyword_match else "分析对象"

        safe_keyword = re.sub(r'[\\/:*?"<>|]', "", keyword)[:20]
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")

        filename = f"sentiment_{safe_keyword}_{timestamp}.md"
        path = os.path.join("./sentiment_reports", filename)

        return {
            "filename": filename,
            "path": path,
        }

    def save_final_output(self, user_query: str, final_output: str) -> str:
        """
        【不是 MCP 标准】把最终对话结果保存到本地 txt 文件。
        """

        def clean_filename(text: str) -> str:
            text = text.strip()
            text = re.sub(r'[\\/:*?"<>|]', "", text)
            return text[:50]

        safe_filename = clean_filename(user_query)
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        filename = f"{safe_filename}_{timestamp}.txt"

        output_dir = "./llm_outputs"
        os.makedirs(output_dir, exist_ok=True)
        file_path = os.path.join(output_dir, filename)

        with open(file_path, "w", encoding="utf-8") as file:
            file.write(f"🗣 用户提问:{user_query}\n\n")
            file.write(f"🤖 模型回复:\n{final_output}\n")

        return file_path

    async def process_query(self, user_query: str) -> str:
        """
        这是整个客户端的“总导演”函数。

        它不负责某个单独技术点,
        它只负责把所有环节按顺序串起来。

        整体顺序如下:

        1. 读取 server 提供的工具列表(MCP)
        2. 提前生成报告文件名(业务逻辑)
        3. 把用户问题增强一下,让模型知道文件名信息(业务逻辑)
        4. 让 LLM 做工具规划(Agent 逻辑,不是 MCP)
        5. 通过 MCP 调工具(MCP)
        6. 让 LLM 根据工具结果生成最终回复(LLM 总结层)
        7. 保存最终结果到本地(业务逻辑)
        """
        available_tools = await self.get_available_tools()

        report_info = self.build_report_filename(user_query)
        report_filename = report_info["filename"]
        report_path = report_info["path"]

        # 给原始问题补充一些工作流上下文,方便模型做规划。
        enhanced_query = (
            user_query.strip()
            + f" [md_filename={report_filename}] [md_path={report_path}]"
        )

        print("\n🧠 开始让 LLM 规划工具调用顺序...")
        tool_plan = await self.plan_tool_usage(enhanced_query, available_tools)
        print("规划结果:")
        print(json.dumps(tool_plan, ensure_ascii=False, indent=2))

        tool_outputs = await self.execute_tool_plan(
            tool_plan=tool_plan,
            report_filename=report_filename,
            report_path=report_path,
        )

        # 把工具输出拼成一个更容易让 LLM 总结的上下文。
        summary_messages = [
            {
                "role": "system",
                "content": (
                    "你是一个结果总结助手。"
                    "请根据以下工具执行结果,给用户一个自然、简洁、清楚的最终回复。"
                ),
            },
            {"role": "user", "content": enhanced_query},
        ]

        for tool_name, tool_text in tool_outputs.items():
            summary_messages.append(
                {
                    "role": "tool",
                    "tool_call_id": tool_name,
                    "content": tool_text,
                }
            )

# 最后喂给模型的 messages,主要是 用户问题 + 一串 tool 结果,并没有把“规划阶段的完整 assistant 推理文本”放进去,所以它更像“基于工具结果做总结”,而不是完整复盘整个规划过程。
final_response
= self.llm_client.chat.completions.create( model=self.model, messages=summary_messages, ) final_output = final_response.choices[0].message.content or "" record_file = self.save_final_output(enhanced_query, final_output) print(f"\n📄 最终对话记录已保存:{record_file}") return final_output async def chat_loop(self) -> None: """ 命令行交互主循环。 输入一句话 -> 执行整个 MCP 工作流 -> 输出结果。 输入 quit -> 退出。 """ print("\n🤖 教学版 MCP 客户端已启动,输入 quit 退出") while True: try: user_query = input("\n你: ").strip() if user_query.lower() == "quit": break final_output = await self.process_query(user_query) print(f"\n🤖 AI: {final_output}") except Exception as exc: print(f"\n⚠️ 发生错误: {exc}") async def cleanup(self) -> None: """ 统一关闭所有异步资源。 """ await self.exit_stack.aclose() async def main() -> None: """ 程序入口。 建议把它理解成: 创建客户端 -> 连接 server -> 进入聊天循环 -> 结束时清理资源 """ # 这里请改成你自己的 server.py 路径。 server_script_path = "D:\\mcp-project\\server.py" client = TeachingMCPClient() try: await client.connect_to_server(server_script_path) await client.chat_loop() finally: await client.cleanup() if __name__ == "__main__": asyncio.run(main())

 

"""
server_beginner_ultimate.py
===========================

这是一个“给小白看的 MCP server 教学版”。

目标:
1. 让你一眼看出:哪些地方是 MCP 标准动作
2. 让你一眼看出:哪些地方只是普通 Python 业务代码
3. 让你理解:server.py 在整个系统里的角色到底是什么

------------------------------------------------------------
一、先用一句人话理解这个文件
------------------------------------------------------------

这个文件不是“负责思考”的。
它的工作只有一个:

    把几个普通 Python 函数,变成 MCP client 可以调用的工具。

你可以把它想成一家小店:

- FastMCP(...)      = 开店
- @mcp.tool()       = 把某个函数挂到货架上,变成“可点单的工具”
- mcp.run(...)      = 开门营业,等 client 来调用

------------------------------------------------------------
二、这个文件在整个系统中的位置
------------------------------------------------------------

用户不直接和 server.py 说话。

真正的流程是:

    用户
      ↓
    client.py
      ↓
    连接到 server.py
      ↓
    读取有哪些工具
      ↓
    调用这里暴露出来的工具

所以你要记住:

- client.py 更像“调度者 / 编排者”
- server.py 更像“工具提供方”

------------------------------------------------------------
三、MCP 在这个文件里到底体现在哪里?
------------------------------------------------------------

MCP 在这个文件里,最核心只体现在 3 个地方:

1. FastMCP("NewsServer")
   -> 创建一个 MCP server

2. @mcp.tool()
   -> 把普通函数注册成 MCP 工具

3. mcp.run(transport="stdio")
   -> 以 stdio 模式启动 server,等待 client 来连接

除了这三类地方以外,其他大部分代码都只是普通 Python 业务逻辑。

也就是说:

- MCP 负责“如何暴露工具”
- 业务代码负责“工具内部到底干什么”

"""

import os
import json
import smtplib
from datetime import datetime
from email.message import EmailMessage

import httpx
from dotenv import load_dotenv
from openai import OpenAI
from mcp.server.fastmcp import FastMCP


# ============================================================
# 第 1 部分:准备环境变量
# ============================================================
# 这一段和 MCP 本身关系不大。
# 它只是普通 Python 程序常见的初始化动作:
# 从 .env 文件里读取配置,例如 API Key、模型名、邮箱密码等。
load_dotenv()


# ============================================================
# 第 2 部分:【MCP 标准动作】创建一个 MCP server
# ============================================================
# 这句非常关键。
#
# 你可以把它想成:
# “现在我要开一家工具店,名字叫 NewsServer”
#
# 后面所有带有 @mcp.tool() 的函数,
# 都会被注册到这个 server 下面,供 client 调用。
mcp = FastMCP("NewsServer")


# ============================================================
# 第 3 部分:工具 1 —— 搜索新闻
# ============================================================
# 【MCP 标准动作】
# @mcp.tool() 的意思是:
# “把下面这个普通函数,注册为一个 MCP 工具”
#
# 没有这个装饰器:
#   这个函数只是你自己代码里的普通函数
#
# 有了这个装饰器:
#   client 可以通过 MCP 协议看到它,并调用它
@mcp.tool()
async def search_google_news(keyword: str) -> str:
    """
    工具名称:
        search_google_news

    这个工具做什么?
        根据关键词搜索 Google 新闻,提取前 5 条结果,
        并把结果保存为一个本地 JSON 文件。

    从“小白视角”怎么理解?
        你可以把它理解成:
        “给我一个关键词,我帮你上网搜新闻,再把结果存起来。”

    参数:
        keyword: 搜索关键词,例如“小米汽车”

    返回:
        一段字符串,里面包含:
        1. 搜到了哪些新闻
        2. 新闻保存到哪个本地文件
    """

    # --------------------------------------------------------
    # 下面开始,都是普通业务代码,不是 MCP 标准
    # --------------------------------------------------------

    # 从环境变量里读取新闻搜索 API 的 key
    api_key = os.getenv("SERPER_API_KEY")
    if not api_key:
        return "❌ 未配置 SERPER_API_KEY,请在 .env 文件中设置"

    # 这里调用的是 Serper 的新闻搜索接口
    # 也就是说,这个工具内部又去调用了一个外部服务
    url = "https://google.serper.dev/news"
    headers = {
        "X-API-KEY": api_key,
        "Content-Type": "application/json",
    }
    payload = {"q": keyword}

    # 发起 HTTP 请求,拿到新闻搜索结果
    async with httpx.AsyncClient() as client:
        response = await client.post(url, headers=headers, json=payload)
        data = response.json()

    # 检查返回结果里是否真的有新闻
    if "news" not in data:
        return "❌ 未获取到搜索结果"

    # 从返回结果中只提取前 5 条新闻
    articles = [
        {
            "title": item.get("title"),
            "desc": item.get("snippet"),
            "url": item.get("link"),
        }
        for item in data["news"][:5]
    ]

    # 把新闻结果保存到本地 JSON 文件,方便后续查看或处理
    output_dir = "./google_news"
    os.makedirs(output_dir, exist_ok=True)

    filename = f"google_news_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
    file_path = os.path.join(output_dir, filename)

    with open(file_path, "w", encoding="utf-8") as f:
        json.dump(articles, f, ensure_ascii=False, indent=2)

    # 返回一段文本给 client
    # 注意:
    # 这里返回的是“字符串”,而不是严格结构化对象
    # 对 demo 没问题,但更严谨的工程代码常会返回更稳定的结构
    return (
        f"✅ 已获取与 [{keyword}] 相关的前5条 Google 新闻:\n"
        f"{json.dumps(articles, ensure_ascii=False, indent=2)}\n"
        f"📄 已保存到:{file_path}"
    )


# ============================================================
# 第 4 部分:工具 2 —— 情感分析
# ============================================================
@mcp.tool()
async def analyze_sentiment(text: str, filename: str) -> str:
    """
    工具名称:
        analyze_sentiment

    这个工具做什么?
        把输入文本交给大模型做情感分析,
        然后把分析结果写成 Markdown 报告。

    从“小白视角”怎么理解?
        你可以把它理解成:
        “给我一段文本,我帮你分析它偏正面还是偏负面,
        然后生成一份报告文件。”

    参数:
        text: 需要分析的新闻文本
        filename: 生成的 Markdown 文件名(不带路径也可以)

    返回:
        生成出来的报告文件路径
    """

    # --------------------------------------------------------
    # 下面依然主要是普通业务代码,不是 MCP 标准
    # --------------------------------------------------------

    # 这个工具内部会自己再调用一次大模型
    # 所以它需要读取模型相关配置
    openai_key = os.getenv("DASHSCOPE_API_KEY")
    model = os.getenv("MODEL")
    base_url = os.getenv("BASE_URL")

    if not openai_key:
        return "❌ 未配置 DASHSCOPE_API_KEY,请在 .env 文件中设置"

    # 创建大模型客户端
    client = OpenAI(api_key=openai_key, base_url=base_url)

    # 组装提示词
    prompt = f"请对以下新闻内容进行情绪倾向分析,并说明原因:\n\n{text}"

    # 调用大模型做分析
    response = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
    )

    result = response.choices[0].message.content.strip()

    # 把分析结果整理成一份 Markdown 报告
    markdown = f"""# 舆情分析报告

**分析时间:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}

---

## 📥 原始文本

{text}

---

## 📊 分析结果

{result}
"""

    # 保存报告
    output_dir = "./sentiment_reports"
    os.makedirs(output_dir, exist_ok=True)

    if not filename:
        filename = f"sentiment_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md"

    file_path = os.path.join(output_dir, filename)

    with open(file_path, "w", encoding="utf-8") as f:
        f.write(markdown)

    # 返回生成后的文件路径
    return file_path


# ============================================================
# 第 5 部分:工具 3 —— 发送带附件的邮件
# ============================================================
@mcp.tool()
async def send_email_with_attachment(
    to: str,
    subject: str,
    body: str,
    filename: str,
) -> str:
    """
    工具名称:
        send_email_with_attachment

    这个工具做什么?
        把某个本地报告文件作为附件,通过 SMTP 发邮件出去。

    从“小白视角”怎么理解?
        你可以把它理解成:
        “给我收件人、标题、正文、附件文件名,我帮你把邮件发出去。”

    参数:
        to: 收件人邮箱
        subject: 邮件标题
        body: 邮件正文
        filename: 附件文件名(默认会去 sentiment_reports 目录里找)

    返回:
        邮件发送成功或失败的说明文字
    """

    # --------------------------------------------------------
    # 下面仍然是普通业务代码
    # --------------------------------------------------------

    # 读取发邮件所需的 SMTP 配置
    smtp_server = os.getenv("SMTP_SERVER")      # 例如 smtp.qq.com
    smtp_port = int(os.getenv("SMTP_PORT", 465))
    sender_email = os.getenv("EMAIL_USER")
    sender_pass = os.getenv("EMAIL_PASS")

    # 构造附件绝对路径
    # 注意:这里默认附件都在 ./sentiment_reports 目录下面
    full_path = os.path.abspath(os.path.join("./sentiment_reports", filename))

    if not os.path.exists(full_path):
        return f"❌ 附件路径无效,未找到文件: {full_path}"

    # 创建邮件对象
    msg = EmailMessage()
    msg["Subject"] = subject
    msg["From"] = sender_email
    msg["To"] = to
    msg.set_content(body)

    # 读取附件并挂到邮件里
    try:
        with open(full_path, "rb") as f:
            file_data = f.read()
            file_name = os.path.basename(full_path)
            msg.add_attachment(
                file_data,
                maintype="application",
                subtype="octet-stream",
                filename=file_name,
            )
    except Exception as e:
        return f"❌ 附件读取失败: {str(e)}"

    # 发送邮件
    try:
        with smtplib.SMTP_SSL(smtp_server, smtp_port) as server:
            server.login(sender_email, sender_pass)
            server.send_message(msg)
        return f"✅ 邮件已成功发送给 {to},附件路径: {full_path}"
    except Exception as e:
        return f"❌ 邮件发送失败: {str(e)}"


# ============================================================
# 第 6 部分:【MCP 标准动作】启动 server
# ============================================================
# 这句是整个文件最后一个最关键的 MCP 动作。
#
# 它的意思是:
# “正式把这个工具服务器跑起来,并使用 stdio 作为通信方式。”
#
# stdio 模式下,通常是 client 去启动这个 server 子进程,
# 然后双方通过 stdin / stdout 交换 MCP 消息。
if __name__ == "__main__":
    mcp.run(transport="stdio")
Service side

 

 

你现在的代码,其实已经在“用 Skill 了”

你写的:execute_tool_plan(...)

 本质就是:

👉 手写 Skill(只是没有叫这个名字)

这便是为何把mcp and skills 写在同一篇博文里的原因。

 

Jeff: 如何构建高效的 execute_tool_plan???

 

Ref: https://www.deeplearning.ai/short-courses/agent-skills-with-anthropic/ 

Ref: 这绝对是B站讲的最好的Agent Skills教程,手把手教学从入门到实战

进一步模块化的结果。

image

 

下面这个既然是最初级的版本,我们就看看 Skill文件夹结构的必要性是什么。

👉 以下 demo code 从“能力角度”看,其实就是 1 个 Skill
👉 skills/ 文件夹,是为“多个 Skill”准备的组织方式

 

这是一整套流程 作为一个skill: 新闻搜索 → 情感分析 → 生成报告 → 发邮件

skills/
└── news_sentiment_report/
    ├── SKILL.md
    ├── scripts/
    │   └── run.py
    ├── references/
    │   └── example_news.json
    └── assets/
        └── report_template.md

 

 

1. SKILL.md

# Skill: 新闻舆情分析并发送报告

## 功能
根据关键词:
1. 搜索相关新闻
2. 进行情感分析
3. 生成 Markdown 报告
4. 发送邮件

## 输入参数
- keyword: 新闻关键词
- email: 收件人邮箱

## 使用时机
当用户需要了解某个话题的舆情趋势,并希望生成报告或发送邮件时使用。

## 执行步骤
1. 调用 search_google_news 获取新闻
2. 调用 analyze_sentiment 分析情绪
3. 调用 send_email_with_attachment 发送报告

## 注意事项
- 如果未提供 filename,使用默认时间戳命名
- 邮件附件来自 sentiment_reports 目录
SKILL.md

 

虽然不等价于 plan_tool_usage()
你可以这样记:

  • plan_tool_usage()临时现想现规划
  • SKILL.md提前写好的固定能力说明

如果硬要类比,SKILL.md 更像你把“过去反复出现的规划经验”沉淀成了一个可复用模块。OpenAI 也明确建议:一旦某个工作流变得可重复,就不要总靠长 prompt 或反复对话,而要把它打包成 Skill。

plan_tool_usage() 是现场即兴指挥,SKILL.md 是提前写好的作战手册。

 

2. scripts/

本质就是:

动态规划 → 通用但复杂(需要 execute_tool_plan
固定流程 → 简单稳定(用 Skill/run.py)

 

3. references

    ├── references/
    │   ├── example_case.md
    │   ├── rules.md
    │   └── output_format.md

 

# 示例:新闻舆情分析

## 输入新闻

标题:小米汽车发布首款车型  
内容:市场反应积极,订单量大幅增长,但也存在供应链压力。

---

## 分析结果(期望格式)

情绪倾向:整体偏正面

原因:
1. 订单增长 → 正面信号
2. 市场反应积极 → 强正面
3. 供应链压力 → 轻微负面

总结:
当前舆情整体偏正面,但需关注供应链风险。
example_case

 

# 情感分析规则

请按照以下规则进行判断:

## 正面信号
- 销量增长
- 市场认可
- 投资增加
- 用户好评

## 负面信号
- 投诉
- 质量问题
- 财务亏损
- 政策风险

## 输出要求
- 必须给出“情绪倾向”
- 必须解释原因
- 必须总结一句话结论
rules

 

# 报告输出格式

必须使用以下结构:

## 📊 情绪分析结果

- 情绪倾向:
- 原因分析:
- 风险点:

## 📌 总结

一句话总结整体舆情
output_format

 

 

  1 from typing import Any, Dict, List
  2 
  3 
  4 async def execute_tool_plan(
  5     self,
  6     tool_plan: List[Dict[str, Any]],
  7     report_filename: str,
  8     report_path: str,
  9 ) -> Dict[str, str]:
 10     """
 11     执行 LLM 给出的工具调用计划。
 12 
 13     这个函数的角色:
 14     1. 接收 plan_tool_usage() 生成的计划
 15     2. 按顺序逐步调用 MCP 工具
 16     3. 把前一步工具结果传给后一步
 17     4. 最后返回所有工具输出结果
 18 
 19     参数:
 20         tool_plan:
 21             LLM 生成的计划,例如:
 22             [
 23                 {"name": "search_google_news", "arguments": {"keyword": "小米汽车"}},
 24                 {"name": "analyze_sentiment", "arguments": {"text": "{{search_google_news}}"}}
 25             ]
 26 
 27         report_filename:
 28             统一使用的报告文件名,例如:
 29             sentiment_小米汽车_20260418_101500.md
 30 
 31         report_path:
 32             报告完整路径。
 33             这里先保留,方便以后扩展。
 34             (本版本中暂未直接使用)
 35 
 36     返回:
 37         一个字典,保存每个工具的输出,例如:
 38         {
 39             "search_google_news": "...",
 40             "analyze_sentiment": "...",
 41             "send_email_with_attachment": "..."
 42         }
 43     """
 44 
 45     # ============================================================
 46     # 步骤 1:先检查 MCP 会话是否已经建立
 47     # 上一步应该已经执行过 connect_to_server()
 48     # 如果还没建立 session,就不能调用任何 MCP 工具
 49     # ============================================================
 50     if self.session is None:
 51         raise RuntimeError("MCP session 尚未建立,请先执行 connect_to_server()")
 52 
 53     # ============================================================
 54     # 步骤 2:准备一个字典,用来保存每个工具的输出
 55     # 这个字典很重要:
 56     # - 它保存每一步真正执行后的结果
 57     # - 后面的步骤可以通过 {{工具名}} 来引用前面的结果
 58     # ============================================================
 59     tool_outputs: Dict[str, str] = {}
 60 
 61     # ============================================================
 62     # 步骤 3:开始按顺序执行 plan 中的每一步
 63     # enumerate(..., start=1) 是为了打印时从“第 1 步”开始,更好懂
 64     # ============================================================
 65     for step_index, step in enumerate(tool_plan, start=1):
 66 
 67         # --------------------------------------------------------
 68         # 步骤:读取当前这一步的工具名 与 参数
 69         # 例如:
 70         #   search_google_news
 71         #   analyze_sentiment
 72         #   send_email_with_attachment
 73         # --------------------------------------------------------
 74         tool_name = step.get("name")
 82         tool_args = step.get("arguments", {}).copy()
 87         if not tool_name:
 88             raise ValueError(f"第 {step_index} 步缺少工具名:{step}")
 89 
 90         print(f"\n==============================")
 91         print(f"第 {step_index} 步:准备调用工具 {tool_name}")
 92         print(f"原始参数:{tool_args}")
 93 
 94         # ========================================================
 95         # 步骤 4:解析参数中的“前一步结果引用”
 96         #
 97         # 例如:
 98         #   "text": "{{search_google_news}}"
 99         #
100         # 这表示:
101         #   不直接写 text,
102         #   而是把前面工具 search_google_news 的输出拿过来
103         #
104         # 这不是 MCP 标准,是本项目自己定义的小规则
105         # ========================================================
106         for key, value in tool_args.items():
107             if isinstance(value, str) and value.startswith("{{") and value.endswith("}}"):
108                 # 去掉花括号,拿到真正的工具名
109                 # "{{search_google_news}}" -> "search_google_news"
110                 ref_tool_name = value.strip("{} ").strip()
111 
112                 # 从之前保存的 tool_outputs 中读取对应结果
113                 # 如果找不到,就保留原值,方便调试
114                 resolved_value = tool_outputs.get(ref_tool_name, value)
115                 tool_args[key] = resolved_value
116 
117         # ========================================================
118         # 步骤 5:自动补默认参数
119         #
120         # 目的:
121         # 有些工具经常需要 filename。
122         # 如果 LLM 规划时没写,我们这里自动补上,减少出错概率。
123         # ========================================================
124 
125         # --------------------------------------------------------
126         # 步骤 5.1:如果是情感分析工具,但没有 filename,就补 report_filename
127         # --------------------------------------------------------
128         if tool_name == "analyze_sentiment" and "filename" not in tool_args:
129             tool_args["filename"] = report_filename
130 
131         # --------------------------------------------------------
132         # 步骤 5.2:如果是发邮件工具,但没有 filename,也补 report_filename
133         # 注意:
134         # server.py 需要的参数名是 filename,不是 attachment_path
135         # --------------------------------------------------------
136         if tool_name == "send_email_with_attachment" and "filename" not in tool_args:
137             tool_args["filename"] = report_filename
138 
139         print(f"解析后参数:{tool_args}")
140 
141         # ========================================================
142         # 步骤 6:【MCP 标准动作】真正调用工具
143         #
144         # 这句是整个函数里最关键的一句:
145         # 它通过 MCP session 去调用 server 端注册好的工具
146         # ========================================================
147         result = await self.session.call_tool(tool_name, tool_args)
148 
149         # ========================================================
150         # 步骤 7:从 MCP 返回结果中提取文本
151         #
152         # 这里默认取第一个 content 的 text。
153         # 对这个 demo 来说已经够用了。
154         # 如果以后工具返回更复杂的结构,这里可以再扩展。
155         # ========================================================
156         if not result.content:
157             tool_text = ""
158         else:
159             tool_text = getattr(result.content[0], "text", "")
160 
161         # ========================================================
162         # 步骤 8:把本步工具结果存起来
163         #
164         # 目的:
165         # 1. 给后面的步骤通过 {{工具名}} 引用
166         # 2. 最后整体返回给上层流程
167         # ========================================================
168         tool_outputs[tool_name] = tool_text
169 
170         print(f"工具 {tool_name} 执行完成")
171         preview = tool_text[:200] + ("..." if len(tool_text) > 200 else "")
172         print(f"输出预览:{preview}")
173 
174     # ============================================================
175     # 步骤 9:所有步骤执行完成,返回所有工具输出
176     #
177     # 这些结果后面可以:
178     # - 用于 final_output 总结
179     # - 用于日志记录
180     # - 用于调试
181     # ============================================================
182     return tool_outputs

 

 

最终,工程进化成了如今MCP + Skills的样子

├── server.py ----> 变化不大
├── README.md
├── requirements.txt
├── client.py
└── skills
    └── news_sentiment_report
        ├── assets
        │   └── report_template.md
        ├── references
        │   ├── example_case.md
        │   └── output_format.md
        ├── scripts
        │   └── run.py
        └── SKILL.md

 

 

有意思的 地方并值得思考的地方在于 choose_skill (。。。) 大模型不再需要plan细节,可以在更高的抽象层面上直接选skills即可。

而这个skills集合,其实可以看做是一个employee的一些基本能力。

  1 """
  2 client.py
  3 =========
  4 
  5 这是一个“教学版小 Agent”。
  6 
  7 它做的事情分 4 步:
  8 
  9 1. 连接 MCP server
 10 2. 读取本地 skills/ 目录里的 Skill
 11 3. 让模型判断当前请求该用哪个 Skill
 12 4. 执行被选中的 Skill
 13 
 14 这个版本的重点是:
 15 让你看懂 “MCP + Skills” 是怎么组合在一起的。
 16 """
 17 
 18 import asyncio
 19 import importlib.util
 20 import os
 21 import re
 22 from pathlib import Path
 23 from typing import Dict, List, Optional
 24 from contextlib import AsyncExitStack
 25 
 26 from dotenv import load_dotenv
 27 from openai import OpenAI
 28 from mcp import ClientSession, StdioServerParameters
 29 from mcp.client.stdio import stdio_client
 30 
 31 load_dotenv()
 32 
 33 
 34 class MCPAndSkillsClient:
 35     """
 36     一个教学版 Agent。
 37 
 38     你可以把它理解成:
 39     - 它像一个“小主管”
 40     - 它知道有哪些 Skill
 41     - 它也能连接 MCP server
 42     - 它决定用哪个 Skill,然后执行 Skill
 43     """
 44 
 45     def __init__(self) -> None:
 46         self.exit_stack = AsyncExitStack()
 47         self.api_key = os.getenv("DASHSCOPE_API_KEY")
 48         self.base_url = os.getenv("BASE_URL")
 49         self.model = os.getenv("MODEL")
 50 
 51         if not self.api_key:
 52             raise ValueError("❌ 未找到 DASHSCOPE_API_KEY,请在 .env 文件中设置")
 53 
 54         self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
 55         self.session: Optional[ClientSession] = None
 56         self.skills_root = Path(__file__).parent / "skills"
 57 
 58     async def connect_to_server(self, server_script_path: str) -> None:
 59         """
 60         连接 MCP server。
 61 
 62         这里使用的是 stdio 模式。
 63         也就是说:client 会自己拉起 server 进程,然后通过 stdin/stdout 通信。
 64         """
 65         is_python = server_script_path.endswith(".py")
 66         is_js = server_script_path.endswith(".js")
 67 
 68         if not (is_python or is_js):
 69             raise ValueError("server 脚本必须是 .py 或 .js")
 70 
 71         command = "python" if is_python else "node"
 72 
 73         server_params = StdioServerParameters(
 74             command=command,
 75             args=[server_script_path],
 76             env=None,
 77         )
 78 
 79         stdio_transport = await self.exit_stack.enter_async_context(
 80             stdio_client(server_params)
 81         )
 82         self.stdio, self.write = stdio_transport
 83 
 84         self.session = await self.exit_stack.enter_async_context(
 85             ClientSession(self.stdio, self.write)
 86         )
 87 
 88         await self.session.initialize()
 89 
 90         response = await self.session.list_tools() ---> 相当于 校验tools是否可用状态
 91         print("\n✅ 已连接到 MCP server")
 92         print("可用 tools:", [tool.name for tool in response.tools])
 93 
 94     def discover_skills(self) -> List[Dict[str, str]]:
 95         """
 96         扫描 skills/ 目录,读取每个 Skill 的最基本信息。
 97         """
 98         skills: List[Dict[str, str]] = []
 99 
100         if not self.skills_root.exists():
101             return skills
102 
103         for skill_dir in self.skills_root.iterdir():
104             if not skill_dir.is_dir():
105                 continue
106 
107             skill_md = skill_dir / "SKILL.md"
108             if not skill_md.exists():
109                 continue
110 
111             text = skill_md.read_text(encoding="utf-8")
112             name_match = re.search(r"^name:\s*(.+)$", text, re.MULTILINE)
113             desc_match = re.search(r"^description:\s*(.+)$", text, re.MULTILINE)
114 
115             skill_name = name_match.group(1).strip() if name_match else skill_dir.name
116             description = desc_match.group(1).strip() if desc_match else "No description"
117 
118             skills.append(
119                 {
120                     "skill_name": skill_name,
121                     "description": description,
122                     "path": str(skill_dir),
123                 }
124             )
125 
126         return skills
127 
128     def choose_skill(self, user_query: str, skills: List[Dict[str, str]]) -> Optional[Dict[str, str]]:
129         """
130         让大模型根据用户请求,判断使用哪个 Skill。
131         """
132         if not skills:
133             return None
134 
135         skills_text = "\n".join(
136             [f"- {s['skill_name']}: {s['description']}" for s in skills]
137         )
138 
139         prompt = f"""你是一个技能路由器(Skill Router)。
140 
141                   下面是当前系统拥有的 Skills:
142                   {skills_text}
143 
144                   用户请求:
145                   {user_query}
146 
147                   请只返回最适合的 skill_name。
148                   如果都不适合,就只返回:none
149 
150                   不要解释,不要返回多余文字。
151                   """
152 
153         response = self.client.chat.completions.create(
154             model=self.model,
155             messages=[{"role": "user", "content": prompt}],
156         )
157 
158         chosen = response.choices[0].message.content.strip()
159 
160         if chosen.lower() == "none":
161             return None
162 
163         for skill in skills:
164             if skill["skill_name"] == chosen:
165                 return skill
166 
167         return None
168 
169     async def execute_skill(self, skill: Dict[str, str], user_query: str) -> str:
170         """
171         执行被选中的 Skill。
172         """
173         if self.session is None:
174             raise RuntimeError("MCP session 尚未建立")
175 
176         skill_dir = Path(skill["path"])
177         run_file = skill_dir / "scripts" / "run.py"
178 
179         if not run_file.exists():
180             raise FileNotFoundError(f"未找到 Skill 脚本: {run_file}")
181 
182         spec = importlib.util.spec_from_file_location("skill_run_module", run_file) --> 把run.py当成模块加载
183         if spec is None or spec.loader is None:
184             raise RuntimeError(f"无法加载 Skill 脚本: {run_file}")
185 
186         module = importlib.util.module_from_spec(spec) --> 创建一个模块对象
187         spec.loader.exec_module(module)
188 
189         if not hasattr(module, "run"):
190             raise RuntimeError(f"{run_file} 中未定义 run()")
191 
192         result = await module.run( --> 真正执行
193             user_query=user_query,
194             session=self.session,
195             llm_client=self.client,
196             model=self.model,
197             skill_dir=skill_dir,
198         )
199         return result
200 
201     async def process_query(self, user_query: str) -> str:
202         skills = self.discover_skills()
203         print("\n📚 当前可用 Skills:")
204         for s in skills:
205             print(f"  - {s['skill_name']}: {s['description']}")
206 
207         chosen_skill = self.choose_skill(user_query, skills)
208 
209         if chosen_skill is None:
210             return "⚠️ 当前没有找到合适的 Skill。"
211 
212         print(f"\n🎯 选中的 Skill: {chosen_skill['skill_name']}")
213         result = await self.execute_skill(chosen_skill, user_query)
214         return result
215 
216     async def chat_loop(self) -> None:
217         print("\n🤖 MCP + Skills 教学版客户端已启动。输入 quit 退出。")
218 
219         while True:
220             try:
221                 user_query = input("\n你: ").strip()
222                 if user_query.lower() == "quit":
223                     break
224 
225                 result = await self.process_query(user_query)
226                 print(f"\n🤖 结果: {result}")
227 
228             except Exception as e:
229                 print(f"\n⚠️ 发生错误: {e}")
230 
231     async def cleanup(self) -> None:
232         await self.exit_stack.aclose()
233 
234 
235 async def main() -> None:
236     project_dir = Path(__file__).parent
237     server_script_path = str(project_dir / "server.py")
238 
239     client = MCPAndSkillsClient()
240     try:
241         await client.connect_to_server(server_script_path)
242         await client.chat_loop()
243     finally:
244         await client.cleanup()
245 
246 
247 if __name__ == "__main__":
248     asyncio.run(main())

 

 

 

Director --> Manager --> Employee Skills

当一个 manager 型 LLM 不再直接调 tool,而是调 skills;再往上还有一个 director 型 LLM 来调 manager 时,这个 manager 是否也应该被封装成一个可调用单元?如果应该,那么 director 本质上是不是就成了更高层的 client / orchestrator?

LangGraph, codex等等,提供了更高层次的抽象。

 

 

再回头看演化路线如下,基本就明白了。

 

AI Agent 工程演化时间线

🟢 1. Prompt Engineering(≈2020–2022)

 
怎么说
 

背景:

  • GPT-3 时代
  • 主要问题:prompt 写不好 → 输出不对

🟢 2. Context Engineering(≈2023)

 
给模型什么信息
 

开始意识到:

  • prompt 不够
  • context 更重要

比如:

  • RAG
  • system prompt
  • memory

🟢 3. Tool Use / Function Calling(≈2023)

(你列表里没写,但必须加)

开始:

  • 模型可以调用函数
  • 接外部能力

🟢 4. MCP(≈2024)

 
工具标准化协议
 

背景:

  • tool 太乱
  • 各家接口不统一

👉 MCP 解决:

 
tool schema + client/server 标准
 

🟢 5. Skills(≈2024–2025)

 
能力模块化
 

背景:

  • tool 太原子
  • 需要更高层 abstraction

👉 Skill = 固定 workflow


🟢 6. Multi-Agent / Subagents(≈2025)

(你其实已经在讨论这个)

 
manager / worker / director
 

🟢 7. Harness Engineering(≈2025–2026🔥)

 
控制 agent 的外部系统
 

背景:

  • agent 开始进入生产环境
  • 开始出问题:
    • 乱调用
    • 不稳定
    • 不可控

👉 开始强调:

  • sandbox
  • logging
  • retry
  • guardrail

🟢 8. Hermes Agent(≈2025–2026)

 
自进化 agent 系统(具体实现)
 

特点:

  • memory
  • skill learning
  • subagents
  • sandbox

👉 属于:

 
“把前面所有思想融合的一种实现”
 
 
Additionally,

OpenClaw 的长期运行能力说明它已经吸收了 Harness Engineering 的思想,
但它仍然是一个“Agent实现”,而不是一个“独立的 Harness 层。

 

posted @ 2026-04-13 22:22  郝壹贰叁  阅读(4)  评论(0)    收藏  举报