Agent来了0x0d:MCP实战-股票分析小助手

0 阅读11分钟

前言

前面学习了 什么是 MCP,及其基本使用方式。那么今天就再用一个更接近实际场景。 想象一下你的老板特别喜欢炒股,当他听到你在 Agent 方面还有点子技术。 那么,是不是能做一个简单的 Agent 来帮助老板分析股票呢? 这时候,关于股票信息的获取、分析等本身不是 LLM 的能力,就需要外挂 MCP 来实现了。

项目背景

这是一个基于 FastAPI-MCP 的股票分析 MCP 服务。

功能亮点:

  • 基于 FastAPI-MCP 构建: 支持 SSE 协议,提供强大的 API 接口。

  • 丰富的股票分析工具: 提供多种股票数据获取和分析工具函数。

  • 多市场支持: 支持 A 股、港股、美股及基金数据分析。

  • 全维度分析:提供股票基础价格、技术指标(MA/RSI/MACD/ 布林带)、综合评分、AI 深度分析等。

  • 集成 AI 分析: 结合大语言模型进行深度分析。

技术亮点:

  • 异步处理: 利用 FastAPI 和 Python 的异步特性,提高并发处理能力。
  • 流式响应: 支持 SSE 协议,实现实时数据流推送。
  • 模块化设计: 各个服务模块职责明确,便于维护和扩展。
  • 依赖注入: 使用 FastAPI 的依赖注入系统,简化代码结构。
  • 全面错误处理: 包含详细的异常捕获和错误响应机制。
  • MCP 工具集成: 使用 FastApiMCP 库自动将 API 转换为 MCP 工具函数。

特别鸣谢

由于我个人并不炒股也不是很懂,所以很多股票相关的代码来自开源软件 stock-scanner 在此,十分感谢🫶🫶🫶。

  • services目录:基本都是 stock-scanner 代码,主要提供股票分析服务

本项目旨在通过 MCP 工具函数接口提供股票相关的综合数据和分析能力,包括价格、评分、技术报告和 AI 分析。 该服务整合了原有项目stock-scanner 中的股票数据获取和分析功能,并提供了现代化的 API 接口,作为前端与后端服务之间的桥梁。我们自己实现的核心主要是:

  • stock_server:MCP 服务端

  • stock_server:MCP 客户端

  • model:LLM 客户端

核心技术

  • FastAPI、FastAPI-MCP

  • LLM

  • MCP

  • asyncio (异步支持)

  • loguru/logger

  • langchain_mcp_adapters

项目架构

image.png

核心流程图

image.png

FastAPI-MCP

定义

FastAPI-MCP 是一个用于 “自动扫描与 MCP 协议转换” 的 Python 标准库。

  1. 自动发现:当你用 FastApiMCP(app) 包装你的 FastAPI 应用时,它会像“扫描仪”一样,自动遍历你应用中所有已注册的路由(Endpoint)。

  2. 元数据提取:它会读取每个路由的 Pydantic 请求/响应模型、类型注解、依赖注入(如认证Depends)以及接口的文档字符串(docstrings)。

  3. 协议转换:将这些信息实时转换为符合模型上下文协议(Model Context Protocol, MCP) 规范的工具描述。

  4. 服务暴露:通过 mcp.mount(),它在你的FastAPI应用中挂载一个额外的MCP服务器端点(默认在/mcp路径)。AI客户端(如Claude Desktop)通过 SSE(Server-Sent Events)连接到此端点,就能发现并调用你所有的API工具。

创建

# 创建FastAPI应用
app = FastAPI(
    title="股票分析 MCP 服务",
    description="基于 FastAPI-MCP 的股票分析服务,提供股票数据、技术分析和AI分析功能"
)


# 创建MCP服务
# 【必须放在所有路由、接口定义 之后!!】
# 【最后几行才写这个】
mcp = FastApiMCP(
    app,
    name="股票分析MCP服务",
    description="基于 FastAPI-MCP 的股票分析服务,提供股票数据、技术分析和AI分析功能"
)

挂载

官方标准

  • mcp.mount():这是最推荐的挂载方式,自动挂载
# 挂载MCP服务,自动挂载。一定要写在所有路由设置之后!!!
# 把 MCP 标准接口(/mcp、/mcp/tools、/mcp/call-tool)自动注册到 FastAPI 里!
mcp.mount()

app手动注册

# 手动挂载所有 MCP 标准路由
app.include_router(mcp.router, prefix="/mcp")

底层手动注册

@app.get("/mcp/tools") def get_mcp_tools(): return [你的工具列表]

和 FastMCP 对比

我们知道FastMCP是一个完整的、用于从头构建MCP服务器和客户端的 Python 原生框架。我们在 MCP 基础里就是用这种框架进行构建的。

维度FastAPI-MCPFastMCP
定位FastAPI到MCP的自动转换器(适配现有API)MCP原生开发框架(从头构建MCP服务)
核心逻辑自动扫描FastAPI路由,提取元数据转换为MCP工具用装饰器(@mcp.tool等)定义Python函数,生成MCP组件(工具/资源/提示)
工作流已有FastAPI应用→包装→自动生成MCP接口定义Python函数→标记装饰器→自动生成MCP服务器
设计哲学API First, AI Enablement(复用存量API,最小化集成成本)MCP First(围绕MCP协议设计,追求协议完整性)
应用场景1. 存量FastAPI服务快速AI化;2. 继承现有安全/业务逻辑;3. 保持API与MCP单一事实来源1. 开发全新AI原生服务/工具;2. 深度定制MCP高级功能(资源/提示);3. 极致协议控制力

Coding

Service

这里代码主要来自 stock-scanner,所以具体实现就不做过多赘述,感兴趣的可以去 git 自己看下。(在此再次感谢 stock-scanner 作者!!!)

简单总结下,我们这里用到的服务:

  • StockDataProvider: 负责从数据源获取股票数据。

  • StockAnalyzerService: 协调数据提供、指标计算、评分和 AI 分析。

  • AIAnalyzer: 调用大语言模型 API 进行深度分析。

  • TechnicalIndicator: 计算技术指标。

  • StockScorer: 根据技术指标评分。

LLM Client

def get_lc_o_model_client(api_key=os.getenv(ALI_TONGYI_API_KEY_OS_VAR_NAME),
                          base_url=ALI_TONGYI_URL,
                          model=ALI_TONGYI_MAX_MODEL, temperature = 0.7, verbose=False, debug=False):
    '''
    以OpenAI兼容的方式,通过LangChain获得指定平台和模型的客户端
    可以通过传入api_key,base_url,model,temperature四个参数来覆盖默认值
    verbose,debug两个参数,分别控制是否输出调试信息,是否输出详细调试信息,默认不打印
    :return: 指定平台和模型的客户端,默认平台和模型为阿里百炼qwen-max-latest,温度=0.7
    '''
    function_name = inspect.currentframe().f_code.co_name
    if(verbose):
        print(f"{function_name}-平台:{base_url},模型:{model},温度:{temperature}")
    if(debug):
        print(f"{function_name}-平台:{base_url},模型:{model},温度:{temperature},key:{api_key}")
    return ChatOpenAI(api_key=api_key, base_url=base_url,model=model,temperature=temperature)

Logger

Logger 模块主要用 loguru 代替系统繁琐的 Log 类。这里我们主要实现了:

  • 分 Level记录日志
    • Info:控制台输出
    • Debug:调试日志保存
    • Error:错误日志保存

可以简单看下,运行后我这里现成保存好的日志:

image.png

image.png

# 添加标准输出处理器(控制台)
logger.add(
    sys.stdout,
    format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
    level="INFO",   # 同时显示在控制台和写入到日志文件中
)

# 添加统一的日志文件处理器,按日期自动轮转
logger.add(
    os.path.join(log_dir, "stock_scanner_{time:YYYY-MM-DD}.log"),
    format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{line} - {message}",
    level="DEBUG",
    rotation="00:00",    # 每天午夜轮转
    retention="7 days",  # 保留7天的日志
    compression="zip",   # 压缩旧日志文件
    enqueue=True         # 使用队列写入,提高性能
)

# 添加错误日志文件处理器,专门记录错误信息
logger.add(
    os.path.join(log_dir, "error_{time:YYYY-MM-DD}.log"),
    format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{line} - {message}",
    level="ERROR",
    rotation="00:00",     # 每天午夜轮转
    retention="7 days",   # 保留7天的错误日志
    compression="zip",    # 压缩旧日志文件
    enqueue=True          # 使用队列写入,提高性能
)
  • 日志清理功能:默认最大存储 7 天
def clean_old_logs(max_days=7):
    """清理超过指定天数的日志文件"""
    try:
        today = datetime.now()
        for filename in os.listdir(log_dir):
            file_path = os.path.join(log_dir, filename)
            # 跳过目录
            if os.path.isdir(file_path):
                continue
                
            # 检查文件修改时间
            file_time = datetime.fromtimestamp(os.path.getmtime(file_path))
            days_old = (today - file_time).days
            
            # 如果文件超过指定天数,删除它
            if days_old > max_days:
                os.remove(file_path)
                logger.info(f"已删除过期日志文件: {filename}")
    except Exception as e:
        logger.error(f"清理日志文件时出错: {e}")
  • 对外getter
def get_logger():
    """获取通用日志器"""
    # 启动时清理旧日志
    clean_old_logs()
    return logger

MCP Server

这里其实就是前面说过的用 FastAPI-MCP 构建。这里是我们今天的重中之重

创建股票分析器

# 创建股票分析服务实例
analyzer = StockAnalyzerService(
    custom_api_url=ALI_TONGYI_URL,
    custom_api_key=os.getenv(ALI_TONGYI_API_KEY_OS_VAR_NAME),
    custom_api_model=ALI_TONGYI_PLUS_MODEL
)

请求模型

# 定义请求模型
class StockAnalysisRequest(BaseModel):
    query: str
    stock_code: str
    market_type: str = "A"

API Route

这里我们一共有 7 个工具(即股票服务),这里仅以 stock_analyzer 为例看看具体实现。其他差不多。

@app.get("/stock_analyzer",
         operation_id="analyze_stock",
         summary="分析单只股票",
         description="通过股票代码和市场类型分析股票,返回包括价格、评分、技术分析和AI分析等综合信息")
async def analyze_stock(
        stock_code: str = Query(..., description="股票代码,如'600795'"),
        market_type: str = Query("A",
                                 description="市场类型,默认为'A'股,可选值:A(A股)、HK(港股)、US(美股)、ETF(场内ETF)、LOF(场内LOF)")
) -> StreamingResponse:
    """
    分析单只股票(流式API)
    Args:
        stock_code: 股票代码
        market_type: 市场类型,默认为'A'股
    Returns:
        以流式响应返回股票分析结果
    """
    logger.info(f"API调用 (流式): analyze_stock({stock_code}, {market_type})")

    async def stream_generator() -> AsyncGenerator[str, None]:
        """异步生成器,用于流式传输分析结果"""
        try:
            # 1. 获取股票数据 (直接调用 data_provider)
            cleaned_stock_code = stock_code
            if market_type == 'A':  # 仅对A股进行前缀清理
                if stock_code.lower().startswith('sh') or stock_code.lower().startswith('sz'):
                    cleaned_stock_code = stock_code[2:]
                    logger.debug(f"股票代码 {stock_code} 已清理为 {cleaned_stock_code}")
            df = await analyzer.data_provider.get_stock_data(cleaned_stock_code, market_type)

            # 3. 检查数据错误
            if hasattr(df, 'error'):
                error_msg = df.error
                logger.error(f"获取股票数据时出错: {error_msg}")
                yield json.dumps({"error": error_msg, "stock_code": cleaned_stock_code, "status": "error"}) + '\n'
                return

            if df.empty:
                error_msg = f"获取到的股票 {cleaned_stock_code} 数据为空"
                logger.error(error_msg)
                yield json.dumps({"error": error_msg, "stock_code": cleaned_stock_code, "status": "error"}) + '\n'
                return

            # 4. 计算技术指标 (直接调用 indicator)
            df_with_indicators = analyzer.indicator.calculate_indicators(df)

            # 5. 计算评分 (直接调用 scorer)
            score = analyzer.scorer.calculate_score(df_with_indicators)
            recommendation = analyzer.scorer.get_recommendation(score)

            # 6. 准备基本分析结果
            latest_data = df_with_indicators.iloc[-1]
            previous_data = df_with_indicators.iloc[-2] if len(df_with_indicators) > 1 else latest_data
            price_change_value = latest_data['Close'] - previous_data['Close']
            change_percent = latest_data.get('Change_pct')
            if change_percent is None and previous_data['Close'] != 0:
                change_percent = (price_change_value / previous_data['Close']) * 100

            ma_short = latest_data.get('MA5', 0)
            ma_medium = latest_data.get('MA20', 0)
            ma_long = latest_data.get('MA60', 0)
            if ma_short > ma_medium > ma_long:
                ma_trend = "UP"
            elif ma_short < ma_medium < ma_long:
                ma_trend = "DOWN"
            else:
                ma_trend = "FLAT"

            macd = latest_data.get('MACD', 0)
            signal = latest_data.get('Signal', 0)
            if macd > signal:
                macd_signal = "BUY"
            elif macd < signal:
                macd_signal = "SELL"
            else:
                macd_signal = "HOLD"

            volume = latest_data.get('Volume', 0)
            volume_ma = latest_data.get('Volume_MA', 0)
            if volume > volume_ma * 1.5:
                volume_status = "HIGH"
            elif volume < volume_ma * 0.5:
                volume_status = "LOW"
            else:
                volume_status = "NORMAL"

            basic_result = {
                "stock_code": stock_code,
                "market_type": market_type,
                "analysis_date": datetime.now().strftime('%Y-%m-%d'),
                "score": score,
                "price": latest_data['Close'],
                "price_change_value": price_change_value,
                "change_percent": change_percent,
                "ma_trend": ma_trend,
                "rsi": latest_data.get('RSI', 0),
                "macd_signal": macd_signal,
                "volume_status": volume_status,
                "recommendation": recommendation,
                "ai_analysis": ""
            }

            # 7. Yield 基本分析结果
            yield json.dumps(basic_result) + '\n'

            # 8. 调用AI分析 (直接调用 ai_analyzer)
            async for analysis_chunk in analyzer.ai_analyzer.get_ai_analysis(df_with_indicators, cleaned_stock_code,
                                                                             market_type, stream=True):
                yield analysis_chunk + '\n'

        except KeyError as ke:
            error_msg = f"股票代码 {stock_code} 在数据源中不存在或格式不正确: {str(ke)}"
            logger.error(f"分析股票时出错: {error_msg}")
            yield json.dumps({"error": error_msg, "stock_code": stock_code, "status": "error"}) + '\n'
        except Exception as e:
            logger.error(f"分析股票时出错: {str(e)}")
            error_message = json.dumps({"error": str(e)})
            yield f"{error_message}\n"

    return StreamingResponse(stream_generator(), media_type="application/x-ndjson")

MCP启动挂载

# 创建MCP服务
# 【必须放在所有路由、接口定义 之后!!】
# 【最后几行才写这个】
mcp = FastApiMCP(
    app,
    name="股票分析MCP服务",
    description="基于 FastAPI-MCP 的股票分析服务,提供股票数据、技术分析和AI分析功能"
)

# 挂载MCP服务,自动挂载。一定要写在所有路由设置之后!!!
# 把 MCP 标准接口(/mcp、/mcp/tools、/mcp/call-tool)自动注册到 FastAPI 里!
mcp.mount()

MCP服务启动

if __name__ == "__main__":
    import uvicorn

    # 获取端口
    port = int(os.getenv("PORT", 8888))
    host = os.getenv("HOST", "0.0.0.0")

    # 启动服务
    uvicorn.run("stock_server:app", host=host, port=port, reload=True

MCP Client

客户端用 MultiServerMCPClient 来承接和转换 MCP 服务提供的工具,转换后可直接应用到 Langchain中使用。我们前面也知道 MultiServerMCPClient 是LangChain MCP 适配器,所以他具备这个功能就不奇怪了。

async def main():
    client = MultiServerMCPClient(
        {
            "Stock": {
                "url": "http://localhost:8888/mcp",
                "transport": "sse",
            }
        }
    )

    tools = await client.get_tools()
    for index, tool in enumerate(tools):
        logger.info(f"第{index + 1}个工具:{tool.name}")

    llm = get_lc_o_ali_model_client()
    logger.info(f"=================准备实际的业务工作=================")

    agent = create_agent(
        model=llm,
        tools=tools
    )

    math_response = await agent.ainvoke(
        {"messages": [{"role": "user", "content": "分析一下 03690 这个怎么样?啥时候值得买"}]}
    )
    logger.info(f"大模型返回参数抽取结果: {math_response}")

Running

image.png

image.png

Cherry Stdio 部署

Cherry Studio 是一款开源、跨平台的多模型AI客户端。其核心逻辑是充当一个“模型路由与聚合层”。

我们以 Cherry Stdio 为例看下我们自己写的 MCP 怎么部署在平台上让别人使用。

📢📢📢前提:LLM 已配置好 阿里千问大模型

  1. 设置- MCP 服务器-添加

image.png

  1. 填写我们 MCP 服务的所在地址,这里填本地地址。

image.png

  1. 保存后,就可以看到 MCP 会自动关联我们的 6 个股票分析工具

image.png

  1. 新建一个 Agent 关联 MCP 服务

image.png

  1. 我们尝试提问一个股票相关的问题,可见过程中确实调用了我们自建的 MCP 服务中的相关工具。

image.png

至此,我们的 MCP 服务已经可以发布并供别人使用了。当然目前我们只是在本地运行,部署到云端后才能公开。

源码

github