从 Function Calling 到 MCP:用 TypeScript 写一个 AI 助手能用的工具服务

过去一年多,我做 AI 应用开发的起点都是同一个需求:让模型别只会聊天,去真正干活------查库、读文件、调内部接口。这个需求的传统解法是 Function Calling(函数调用):在请求里描述一堆函数,模型决定调哪个、传什么参,客户端负责执行再把结果喂回去。

能用,但有两个问题一直烦人:各家模型的 Function Calling 格式互不兼容;工具本身无法复用,换个 Host(宿主应用)就得把胶水代码重写一遍。

MCP(Model Context Protocol,模型上下文协议)就是为解决这两件事而来的。这篇文章我用 TypeScript 从零写一个能被 Claude Desktop、Cursor 等客户端直接调用的 MCP Server------一个本地 Markdown 笔记检索服务。全文代码可以直接跑,读完你能得到:协议核心概念、一个完整 Server 实现、以及我踩过的三个坑。

版本说明:Node 20+、@modelcontextprotocol/sdk 1.x、zod 3.x。协议规范迭代较快,以官方文档为准,客户端与服务端会在初始化阶段协商协议版本。

MCP 是什么:先说结论

MCP 的本质,是把"AI 应用如何发现和调用外部工具"这件事,标准化成了一个基于 JSON-RPC 2.0 的开放协议。

官方定义:MCP 是一个开放协议,为 AI 应用连接外部数据源和工具提供标准化接口。------ modelcontextprotocol.io

它定义了三个角色:

  • Host(宿主) :AI 应用本体,比如 Claude Desktop、Cursor、你自己写的 Chat 客户端
  • Client(客户端) :Host 内部为每个 Server 维护的协议连接,负责 JSON-RPC 消息收发
  • Server(服务端) :暴露工具和数据的服务进程,比如我们下面要写的笔记检索服务

一次典型的调用流程是:Host 把用户请求发给模型,模型根据上下文决定需要某个工具,Host 通过 Client 向 Server 发起 tools/call 请求,Server 执行后返回结果,结果回填给模型继续推理。

图片:Host-Client-Server 三者关系与一次工具调用的时序图,建议尺寸 900×383

Server 可以对外暴露三类能力:

  1. Tools(工具) :可被模型调用的函数,比如"搜索笔记"、"创建工单"
  2. Resources(资源) :可被读取的数据,比如文件内容、数据库表结构
  3. Prompts(提示模板) :预置的提示词模板,供用户主动调用

传输层(transport)有两种:stdio 用于本地进程通信,Server 作为子进程启动;Streamable HTTP 用于远程服务,支持鉴权与多客户端。本文用 stdio,够简单也够典型。

看到这里你可能会问:这跟 Function Calling 有什么区别?一句话:Function Calling 是"模型的能力",MCP 是"生态的约定" 。前者解决模型怎么决定调函数,后者解决工具怎么被所有客户端复用------你写一次 Server,任何支持 MCP 的 Host 都能直接用,不用再写一行集成代码。

动手:写一个笔记检索 Server

1. 初始化项目

bash

bash 复制代码
mkdir mcp-notes && cd mcp-notes
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/node

目录结构:

text

bash 复制代码
mcp-notes/
├── server.ts   # Server 本体
└── ~/notes/    # 被检索的笔记目录,放几个 .md 文件即可

2. 核心代码

先上完整实现,不到 60 行:

ts

typescript 复制代码
// server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import fs from "node:fs/promises";
import path from "node:path";


// 被检索的笔记目录,按需修改
const NOTES_DIR = path.join(process.env.HOME ?? ".", "notes");


const server = new McpServer({
  name: "notes-search",
  version: "0.1.0",
});


server.tool(
  "search_notes", // 工具名:模型用它来定位工具
  "在本地 Markdown 笔记中按关键词全文检索,返回文件名和上下文片段",
  {
    // 参数 schema 用 zod 描述,SDK 会转成 JSON Schema 发给客户端
    keyword: z.string().describe("要检索的关键词"),
  },
  async ({ keyword }) => {
    const files = (await fs.readdir(NOTES_DIR)).filter((f) => f.endsWith(".md"));
    const hits: string[] = [];
    for (const f of files) {
      const content = await fs.readFile(path.join(NOTES_DIR, f), "utf-8");
      const idx = content.indexOf(keyword);
      if (idx !== -1) {
        const start = Math.max(0, idx - 40);
        hits.push(`**${f}** ...${content.slice(start, idx + keyword.length + 60)}...`);
      }
    }
    return {
      content: [{ type: "text", text: hits.length ? hits.join("\n\n") : "没有匹配的笔记" }],
    };
  }
);


// stdio 传输:作为子进程被 Host 启动,通过标准输入输出收发消息
const transport = new StdioServerTransport();
await server.connect(transport);

几个关键点:

  • server.tool() 的四个参数:工具名、描述、参数 schema、实现函数。描述是写给模型看的,它直接决定模型什么时候调用这个工具、怎么传参(这是坑三的伏笔)
  • 参数校验用 zod(一个 TypeScript 优先的 schema 校验库),SDK 自动把它转成 JSON Schema 随 tools/list 返回给 Host
  • 返回值是 content 数组,支持 text / image / audio 等类型,本文只用到文本

3. 接到客户端上

以 Claude Desktop 为例,编辑配置文件(macOS 路径是 ~/Library/Application Support/Claude/claude_desktop_config.json):

json

json 复制代码
{
  "mcpServers": {
    "notes-search": {
      "command": "npx",
      "args": ["-y", "tsx", "/absolute/path/to/mcp-notes/server.ts"]
    }
  }
}

重启 Claude Desktop,输入框旁出现工具图标,就说明 Server 已被发现。直接问它:"帮我找一下笔记里提到 MCP 的内容",就能看到完整的调用链路。

Cursor 的接法一样,把同样的 mcpServers 结构写进项目根目录的 .cursor/mcp.json 即可。这就是 MCP 的价值:一次实现,处处可用------同一个 Server,两个客户端零代码接入。

图片:Claude Desktop 中的调用效果截图,建议尺寸 900×383

踩坑记录

真实项目里,协议代码往往不是难点,难点在这些边角。

坑一:console.log 把协议搞坏了

  • 现象:Server 单独跑没问题,接进 Claude Desktop 后工具不出现
  • 排查 :打开 ~/Library/Logs/Claude/ 目录下的 mcp-server-notes-search.log,发现协议初始化阶段就断连
  • 原因 :stdio 传输下,标准输出 stdout 就是协议通道。我在代码里随手打了一行 console.log("启动成功"),这行字符串对 JSON-RPC 解析器来说就是非法消息,直接导致连接崩溃
  • 解法 :所有调试日志改用 console.error(走 stderr,不影响协议),或封装一个写文件的 logger。这应该作为 stdio Server 的编码纪律

坑二:npx 冷启动超时

  • 现象 :配置里用 npx -y tsx 启动,客户端偶尔提示连接超时
  • 排查 :手动计时 npx -y tsx server.ts,冷启动要下载依赖,网络差时超过 10 秒
  • 原因:Host 对 Server 启动有时间预期,npx 首次运行的包下载开销不可控
  • 解法 :发布场景先 tsc 编译成 JS 用 node 直接启动;开发场景先全局安装 tsx,让启动路径不再依赖网络

坑三:模型不调用工具,或乱传参数

  • 现象:工具明明注册了,模型要么无视它直接编答案,要么传了奇怪的关键词
  • 排查:把工具的 description 从"搜索笔记"改成一句话场景描述后,调用率明显变化
  • 原因:工具的 name 和 description 本质上是 prompt 的一部分。描述含糊,模型就无法判断"什么时候该用我"
  • 解法 :description 写成"动作 + 范围 + 返回什么"的完整句子;用 zod 的 .describe() 给每个参数补充语义。把工具描述当作 API 文档,写给一个不了解你系统的新同事看

总结

  • MCP 把工具调用标准化为基于 JSON-RPC 2.0 的开放协议,核心角色是 Host / Client / Server,Server 暴露 Tools、Resources、Prompts 三类能力
  • 用官方 TypeScript SDK 写一个 stdio Server 不到 60 行,重点是 server.tool() 的四要素:名称、描述、schema、实现
  • stdio 传输下 stdout 是协议通道,调试日志必须走 stderr
  • 工具描述是 prompt 的一部分,写得越具体,模型调用越准

适用边界:如果你的应用只对接单一模型、工具只在内部用,直接用 Function Calling 更简单,不必为了 MCP 而 MCP;当工具需要跨客户端复用(比如多个 AI 应用共享一套内部能力),或者你想接入现成的社区 Server 生态时,MCP 标准化的收益才真正体现出来。

协议与 SDK 都在快速迭代,文中代码以官方文档为准:modelcontextprotocol.io。

相关推荐
Mutualgoodnight1 小时前
我把麦当劳官方 MCP 的 35 个工具全测了一遍,几个坑文档里没写
mcp
xrlfreedom5 小时前
MCP 面试高频考点:如何用 JSON Schema、OAuth 2.1 与 OpenTelemetry 落地可复用 Prompts 工作流
json schema·opentelemetry·mcp·oauth 2.1
盈米AI开放平台6 小时前
DeepSeek怎么辅助投资理财?MCP接入与能力边界说明
人工智能·金融·投资理财·deepseek·mcp
余槐i12 小时前
Claude Code Skills 放对目录却不生效:插件启用顺序与缓存的三个坑
git·开发工具·ai agent·mcp·claude code
古法老登瞎折腾12 小时前
大数据智能运维实战(三):实现第一个运维 MCP 工具——查询节点与组件状态
java·大数据·运维·人工智能·spring ai·mcp
古法老登瞎折腾12 小时前
大数据智能运维实战(六):任务一直在等——排查调度队列与执行资源
java·大数据·运维·spring ai·mcp
殷紫川1 天前
Context7 :给 AI 编码代理接上实时文档的 “活水”
aigc·ai编程·mcp
Behavior1 天前
某餐饮品牌开放 MCP 接口被玩成消费级 Agent:WorkBuddy 搭出会算营养、比价格、能下单的减脂私教
aigc·agent·mcp
王洲聪1 天前
coding小白参加金拱门项目接餐饮MCP时踩过的 4 个坑
mcp