MCP 真正解决的不是再发明一个协议,而是把工具接入做成通用插座

每个项目都在重复造工具

上一篇结尾留了个问题:每做一个 Agent 项目,都在重复造工具。这个项目写一遍查数据库的工具,下个项目又写一遍;给自己的 Agent 写的搜索工具,没法直接拿到别人的应用里用。工具和应用绑死了。

MCP 要解决的就是这件事。这是进阶系列的最后一篇,先把 MCP 和已经熟悉的 Function Calling 摆在一起讲清楚关系,然后动手写一个真正能跑的 Server,最后讲场景设计和调试测试。

MCP 和 Function Calling 不是二选一

很多人第一次听到 MCP,会以为它要取代 Function Calling。不是的,它们在不同的层。

回忆一下 Function Calling 解决的是什么——它解决的是"模型怎么表达一个调用意图"。但它没解决工具的复用问题:工具的 schema 和实现仍然是你在每个应用里各写各的,和这个应用焊死。

MCP(Model Context Protocol)是 Anthropic 提出的一个开放协议,它往上加了一层标准化。打个比方:Function Calling 让模型学会了"说出我要调某个函数",而 MCP 定义了一种统一的插座标准——你把工具实现成一个独立的 Server,任何支持 MCP 的客户端,不管是 Claude Desktop、某个 IDE,还是你自己写的 Agent,都能即插即用地连上来用它的工具。工具一次实现,到处可用。

所以两者是协作关系:MCP Server 对外暴露的工具,最终被模型调用时,走的还是 Function Calling 那套机制。MCP 标准化的是工具怎么被发现、被描述、被连接这一层,底下的调用意图表达,依然是 Function Calling。

具体说,一个 MCP Server 可以暴露三类东西,协议里叫 primitives。Tools 是可执行的函数,会产生动作或副作用,对应你熟悉的工具调用。Resources 是可读取的数据,类似 HTTP 的 GET,比如一个文件、一段数据库记录,由应用决定何时读取。Prompts 是预设的提示词模板,供用户主动调用。架构上是 Host(如 Claude Desktop)通过内置的 Client 连接若干 Server,一个 Host 可以同时连多个 Server。传输方式有两种:本地 Server 用 stdio,把 Server 作为子进程通过标准输入输出通信;远程 Server 用 HTTP。

从零写一个 MCP Server

讲再多不如写一个。用官方的 Python SDK,里面的 FastMCP 把样板代码都封装好了,写起来和定义普通函数差不多。

# server.py
import sqlite3
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("notes-server")


@mcp.tool()
def search_notes(keyword: str, limit: int = 5) -> list[dict]:
    """在本地笔记库中按关键词搜索笔记。

    用于用户想查找自己记录过的内容时。
    keyword 是要搜索的关键词,limit 是返回的最大条数。
    """
    conn = sqlite3.connect("notes.db")
    rows = conn.execute(
        "SELECT title, body FROM notes WHERE body LIKE ? LIMIT ?",
        (f"%{keyword}%", limit),
    ).fetchall()
    conn.close()
    return [{"title": t, "excerpt": b[:200]} for t, b in rows]


@mcp.tool()
def add_note(title: str, body: str) -> dict:
    """新增一条笔记。这是写入操作,会持久化到本地数据库。

    title 是笔记标题,body 是正文内容。
    """
    conn = sqlite3.connect("notes.db")
    conn.execute("INSERT INTO notes (title, body) VALUES (?, ?)", (title, body))
    conn.commit()
    conn.close()
    return {"ok": True, "title": title}


@mcp.resource("notes://recent")
def recent_notes() -> str:
    """最近修改的 10 条笔记标题,作为可读资源供客户端按需加载。"""
    conn = sqlite3.connect("notes.db")
    rows = conn.execute(
        "SELECT title FROM notes ORDER BY updated_at DESC LIMIT 10").fetchall()
    conn.close()
    return "\n".join(t for (t,) in rows)


if __name__ == "__main__":
    mcp.run(transport="stdio")

这里有个细节值得停一下:全程没有手写任何 JSON Schema。FastMCP 直接从类型注解和 docstring 自动生成工具的 schema——参数类型来自注解,工具描述来自 docstring。这正好呼应第五篇讲工具设计时强调的:描述是模型选对工具的唯一依据。在 MCP 里,函数的 docstring 就是那段决定成败的描述,所以别偷懒,把"什么时候用、参数什么含义"写清楚。注意 add_note 的 docstring 里明确写了"这是写入操作",这样客户端和模型都能意识到它有副作用。

接入客户端

写完 Server 怎么用。以 Claude Desktop 为例,在它的配置文件里加上这个 Server:

{
  "mcpServers": {
    "notes-server": {
      "command": "python",
      "args": ["/绝对路径/server.py"]
    }
  }
}

重启客户端,它会把这个 Server 作为子进程拉起来,工具自动出现在可用列表里。这里有个常见的坑:路径一定要写绝对路径。客户端拉起子进程时的工作目录通常不是你的项目目录,相对路径会找不到文件。同理,Server 里读 notes.db 也最好用绝对路径,或在代码里基于 __file__ 拼出绝对路径。

如果要让自己写的 Agent 连这个 Server,用的是 MCP 的 client SDK,连接后能列出 Server 暴露的 tools,再把它们转成模型能用的工具定义——这一步之后,就回到了第五篇讲的 Function Calling 循环。

什么该做成 MCP,什么不该

能写 Server 之后,更重要的判断是:什么该做成 MCP,什么不该。把所有工具无脑都包成 MCP Server,是另一种过度工程。

适合做成 MCP 的,是那些需要被复用的能力:会被多个项目、多个客户端共用的工具;通用而稳定的能力,比如查公司内部知识库、访问某个标准数据源;需要跨团队共享的东西。这些场景下,MCP 那层标准化的接入开销,换来的是"一次实现、处处可用",很值。

不适合的也很明确。一个逻辑如果只在单个应用内部用一次,直接用 Function Calling 写个工具就好,再套一层 MCP 协议、多一个进程、多一道通信,纯属负担。高频、对延迟极敏感、和主程序紧耦合的内部调用,也不该跨进程走 MCP。带复杂事务、复杂状态的逻辑,做成无状态的工具接口本就别扭,硬塞进 MCP 只会更别扭。一句话:MCP 的价值在复用边界上,没有跨边界复用的需求,就别引入它。

还有个常被忽略的安全问题必须摆出来。MCP Server 跑的是真实代码——当你安装并连接一个第三方的 MCP Server,等于在自己的机器上执行别人写的代码,这是实打实的供应链风险。所以来路不明的 Server 要审代码,密钥和权限要按最小化原则给。另外,工具的返回值会进入模型上下文,恶意的返回内容可能携带 prompt injection,这一点在第二篇讲过,在 MCP 场景下同样要防。

调试与测试

调试 MCP Server 之前,先记住一个几乎人人都会踩的坑:stdio 传输的 Server,绝对不能用 print 往标准输出打日志。原因很简单——stdout 这条通道已经被 MCP 协议本身占用,是 Server 和 Client 通信的管道。往里 print 一行调试信息,就等于往协议流里塞了段垃圾,Client 解析直接乱套。日志要打到 stderr,或者写文件:

import logging
import sys

# stdio 传输下,日志必须走 stderr,不能用 print 打到 stdout
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
logger = logging.getLogger("notes-server")

调试 MCP Server 的官方利器是 MCP Inspector。它是一个可视化工具,能直接连上你的 Server,把暴露的 tools、resources、prompts 全列出来,可以手动填参数调用、看真实返回,不需要先接进某个客户端再绕一大圈。开发阶段先用 Inspector 把每个工具单独验通,再去接客户端,能省掉大量"到底是 Server 错了还是接入配置错了"的纠结。

测试分两个层次。单元测试很轻松——@mcp.tool() 装饰的函数本质还是普通函数,工具逻辑和 MCP 协议是解耦的,直接像测普通函数那样导入、调用、断言即可:

# test_server.py
from server import search_notes, add_note


def test_add_and_search():
    add_note("MCP 调试笔记", "stdio 下日志要走 stderr")
    results = search_notes("stderr")
    assert any("MCP 调试笔记" == r["title"] for r in results)

集成测试则用 MCP 的 client SDK 真正连上 Server,走一遍完整的握手、列举、调用流程,确认 schema 生成正确、传输正常。常见的集成问题就那么几类:类型注解不规范导致 schema 生成不对、路径写成了相对路径、Server 依赖的环境变量没传进子进程——排查时优先看这几处。

几个常见踩坑

以为 MCP 要取代 Function Calling。MCP 是更上层的接入标准化,底层调用仍走 Function Calling。

把所有工具都包成 MCP Server。只在单应用内用一次的逻辑,直接 Function Calling 即可,套 MCP 是负担。

配置里用相对路径。客户端拉起子进程的工作目录不确定,命令和文件路径都要用绝对路径。

stdio Server 用 print 打日志。stdout 是协议通道,日志必须走 stderr 或写文件。

随意安装第三方 MCP Server。Server 跑的是真实代码,等于在本机执行他人代码,要审代码、按最小权限授权。

docstring 写得太随意。FastMCP 用 docstring 当工具描述,写不清楚模型就用不对工具。

本篇要点

  • MCP 是工具接入的标准化协议,和 Function Calling 是协作关系,不是替代关系
  • MCP Server 暴露 Tools、Resources、Prompts 三类能力,本地用 stdio、远程用 HTTP 传输
  • 用 FastMCP 写 Server,schema 由类型注解和 docstring 自动生成,docstring 即工具描述
  • 接入客户端要用绝对路径;有跨项目复用需求才做成 MCP,否则直接 Function Calling
  • 第三方 Server 有供应链风险,要审代码、按最小权限授权
  • stdio Server 的日志必须走 stderr;用 MCP Inspector 调试,工具函数可直接做单元测试

系列结语

到这里,进阶系列七篇就走完了。回头看这条线索其实很清晰:第一篇把 API 调用调到能上线,第二篇把 Prompt 做成可评估的工程,第三、四篇把检索从能跑通做到能答准,第五篇让模型学会用工具,第六篇把工具、记忆、控制流组织成 Agent,第七篇又把工具的接入本身标准化成可复用的 MCP Server。

从"调通一个 API",到"搭起一个能复用、能协作、能维护的系统"——这中间的每一步,都不是玄学,而是一件件可以拆开、可以度量、可以做扎实的工程。

参考资料

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:MCP:自己动手写一个 Server

本文链接:https://www.sshipanoo.com/blog/ai/llm-advanced/07-MCP自己动手写一个Server/

本文最后一次更新为 天前,文章中的某些内容可能已过时!