「完全理解」MCP 到底是什么?从零开始实现一个完整的 MCP 调用链

999 阅读10分钟

背景

最近 3 个月时间里,AI 领域中最火热的概念毫无疑问就是 MCP

  • 各个平台在提供 MCP 支持能力,并作为更新亮点

  • 各个应用在提供 MCP 工具,提供给用户接入

  • 各个开发都在卷 MCP 接入,立志于集成各种工具

所以 MCP 到底是什么?上手体验是怎么样?

本文尝试用几个简单的案例从零开始复刻一个小型 MCP 全流程

相关代码已上传到 github - github.com/qqqqqcy/mcp…

1. 简单的 LLM

LLM(大型语言模型)是基于深度学习和 Transformer 架构的超大规模人工智能模型,参数规模通常达数百亿至万亿级别,核心能力为文本生成与理解,并逐步扩展至多模态处理(如图像分析)。典型代表包括 OpenAI 的 GPT-4(支持图文输入)、Anthropic 的 Claude 3(长文本与多模态)及深度求索的 DeepSeek-V3(高效开源模型)

1.1 通过 Chat 体验 LLM

LLM 最直观、最常见的使用场景,就是模拟对话

我们通过市面上各种 Chat 客户端,可以与不同的 LLM 来对话

1.2 通过 API 调用 LLM

现在市面上也有各种成熟的大语言模型 api 可以提供给用户任意调用,以火山为例

1.2.1 定义一个通用的调用方法

// config.js
export default {
  ark_api_key: "YOUR_API_KEY 需要替换为您在平台创建的 API Key",
  model: "deepseek-v3-250324",
  url: "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
}
// 1_llm/index.js
import config from "../config.js";

export const llmCall = async (params) => {
  const response = await fetch(config.url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${config.ark_api_key}`,
    },
    body: JSON.stringify({
      model: config.model,
      ...params,
    }),
  });
  return await response.json();
};

1.2.2 测试

// 1_llm/test.js
import { llmCall } from "./index.js";

const messages = [
  {
    role: "user",
    content: "天空为什么是蓝色的?",
  },
];
const res = await llmCall(messages);
console.log(JSON.stringify(res));
{
  "choices": [
    {
      "finish_reason": "stop",
      "index": 0,
      "logprobs": null,
      "message": {
        "content": "天空呈现蓝色是由于**瑞利散射(Rayleigh Scattering)**现象,这是阳光与地球大气层相互作用的结果。以下是详细的解释:\n\n---\n\n### 1. **阳光的组成**\n太阳发出的白光由不同波长的光组成(红、橙、黄、绿、蓝、靛、紫等)。其中,蓝光(波长较短,约450-495纳米)和紫光(波长更短)比红光(波长较长,约620-750纳米)具有更高的频率和能量。\n\n---\n\n### 2. **大气层的作用**\n当阳光穿过地球大气层时,会与空气中的气体分子(如氮气、氧气)以及微小颗粒发生碰撞。这些分子和颗粒的尺寸远小于光的波长,导致**瑞利散射**发生:  \n- **短波光(蓝/紫光)散射更强**:根据瑞利散射定律,散射强度与波长的四次方成反比(\( I \propto 1/\lambda^4 \)),因此蓝光比红光更容易被散射到四面八方。  \n- **长波光(红光)散射较弱**:红光波长较长,散射程度小,更容易直接穿过大气层。\n\n---\n\n### 3. **人眼感知**\n- 虽然紫光的波长比蓝光更短、散射更强,但人眼对紫光的敏感度较低,且太阳光谱中紫光的能量较少。因此,综合散射强度和视觉感知后,我们主要看到的是**蓝色**。  \n- 散射的蓝光从各个方向进入人眼,使得整个天空呈现蓝色,而太阳本身看起来偏黄(因为部分蓝光已被散射掉)。\n\n---\n\n### 4. **其他现象**\n- **日出/日落时的红色**:当太阳靠近地平线时,阳光需穿过更厚的大气层,蓝光被大量散射,剩余的红光直达人眼,导致天空呈现红橙色。  \n- **高空或太空的颜色**:在太空或极高海拔处,因缺乏大气散射,天空看起来是黑色的。\n\n---\n\n### 总结\n天空的蓝色本质上是阳光中的短波蓝光被大气分子强烈散射的结果,而人眼和太阳光谱的特性进一步强化了这一视觉效果。这一现象由19世纪的物理学家**瑞利勋爵**(Lord Rayleigh)首次科学解释。",
        "role": "assistant"
      }
    }
  ],
  "created": 1745845778,
  "id": "0217458457588550802b9ec61ff2a96f40f4288f2c076e8ef7881",
  "model": "deepseek-v3-250324",
  "service_tier": "default",
  "object": "chat.completion",
  "usage": {
    "completion_tokens": 477,
    "prompt_tokens": 8,
    "total_tokens": 485,
    "prompt_tokens_details": {
      "cached_tokens": 0
    },
    "completion_tokens_details": {
      "reasoning_tokens": 0
    }
  }
}

1.3 总结

image.png 我们用 1min 不到就学会了使用 LLM,但是这种简单的调用存在一些明显的局限性,例如:

  • 静态知识:训练数据截止后,无法获取最新信息(如天气、股价)。

  • 精确性不足:复杂数学计算、代码执行可能出错。

  • 缺乏专业能力:医疗诊断、法律咨询等需依赖权威数据库。

究竟|9.11比9.8大?大模型们为何会在小学数学题上集体翻车 - www.thepaper.cn/newsDetail_…

接下来我们来通过 Function Call 解决这些问题

2. 貌似神奇的 Function Call

2.1 Function Call 是什么?

Function Call(函数调用)是一种让 LLM 与外部工具或 API 交互的能力。当 LLM 遇到无法独立解决的问题(如实时数据查询、精确计算、专业领域知识等)时,可以通过调用预设的函数来获取准确结果,再将信息整合到回答中。

image.png

Function Call 可以很好的解决 LLM 的局限性,通过「外包」任务弥补这些短板,例如:

  • 调用搜索引擎获取实时新闻;

  • 连接计算工具完成方程求解;

  • 访问数据库查询专业资料。

2.2 Function Call 的原理

其原理很简单,我们都知道 LLM 可以通过 Prompt 来限定其输出

所以通过提供可调用函数列表、函数功能说明、入参规范,我们就可以得到预期的返回

基于返回,我们再去调用已有的方法就能得到准确的结果

// 2_function_call/test.js


const messages = [
  {
    role: "user",
    content: "今天的天气",
  },
];

const res = await llmCall({
  messages: [
    {
      role: "system",
      content: `
你是一个智能函数助手。请根据用户输入完成两个任务:
1. 从可用函数列表中选择最合适的函数
2. 根据选择的函数的参数模式提取参数

可用函数列表:
${JSON.stringify([
  {
    name: "add",
    description: "计算两个数字的和",
    parameters: {
      type: "object",
      properties: {
        a: { type: "number" },
        b: { type: "number" },
      },
    },
  },
  {
    name: "search",
    description: "百度搜索",
    parameters: {
      type: "object",
      properties: {
        query: { type: "string" },
      },
    },
  },
])}

请以JSON格式返回结果,格式为:
{
"functionName": "选择的函数名",
"parameters": {根据选择函数的schema提取的参数}
}
`,
    },
    ...messages,
  ],
});

console.log(JSON.stringify(res));
{
  "choices": [
    {
      "finish_reason": "stop",
      "index": 0,
      "logprobs": null,
      "message": {
        "content": "```json\n{\n  "functionName": "search",\n  "parameters": {\n    "query": "今天的天气"\n  }\n}\n```",
        "role": "assistant"
      }
    }
  ],
  "created": 1745914413,
  "id": "0217459144113995c9442e2ce6b139b5364a55668e364791cae17",
  "model": "deepseek-v3-250324",
  "service_tier": "default",
  "object": "chat.completion",
  "usage": {
    "completion_tokens": 29,
    "prompt_tokens": 143,
    "total_tokens": 172,
    "prompt_tokens_details": {
      "cached_tokens": 0
    },
    "completion_tokens_details": {
      "reasoning_tokens": 0
    }
  }
}

--- content ---
{
  "functionName": "search",
  "parameters": {
    "query": "今天的天气"
  }
}

image.png

2.3 tools

实际上,当前市面上主流的 LLM API,都已经通过 tools(不同 API 入参会存在一些差异)内置了 Function Call 能力,不需要大家自己去实现 promot

基于此,我们可以快速实现一个带「插件」功能的小型 LLM 应用

// 3_tools/test.js

import { z } from "zod";
import { llmCall } from "../1_llm/index.js";

// 工具定义集合
const TOOLS = {
  // 加法
  add: {
    name: "add",
    description: "计算两个数字的和",
    parameters: z.object({
      a: z.number().describe("第一个数字"),
      b: z.number().describe("第二个数字"),
    }),
    execute: ({ a, b }) => {
      const result = a + b;
      return result;
    },
  },

  // 搜索
  search: {
    name: "search",
    description: "百度搜索",
    parameters: z.object({
      query: z.string().describe("搜索关键词"),
    }),
    execute: async ({ query }) => {
      const response = await fetch(
        `https://www.baidu.com/s?wd=${encodeURIComponent(query)}`,
        {
          headers: {
            "User-Agent":
              "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
          },
        }
      );
      const html = await response.text();
      const regex = /<h3 class="t">.*?<a.*?>(.*?)<\/a>.*?<a.*?href="(.*?)".*?>/;
      const match = regex.exec(html);
      if (!match) {
        return "没有找到相关结果";
      }
      const title = match[1].replace(/<[^>]+>/g, "").trim();
      const url = match[2];
      return `\n${title}\n${url}`;
    },
  },
};

const messages = [
  {
    role: "user",
    content: "北京今天的天气",
  },
];

const res = await llmCall({
  messages,

  // 工具注册
  tools: Object.values(TOOLS).map((tool) => ({
    type: "function",
    function: {
      name: tool.name,
      description: tool.description,
      parameters: tool.parameters.shape,
    },
  })),
});

// 执行工具
async function executeTool(response) {
  const message = response.choices[0].message;

  // 可能不适配任何工具
  if (!message.tool_calls) {
    return message.content;
  }

  const toolCall = message.tool_calls[0].function;
  console.log("fn 调用:", toolCall);
  return await TOOLS[toolCall.name].execute(JSON.parse(toolCall.arguments));
}
const result = await executeTool(res);

console.log(`结果: ${result}`);
fn 调用: { arguments: '{"query":"北京今天的天气"}', name: 'search' }
结果: 
北京今天的天气的最新相关信息
http://www.baidu.com/link?url=sJKlZP_6IqY6kM0WYcn2tvrpP01_Pucj8tbdI9Whw34S0N9iFFuUVUUpQTKYJiBc_JKGonrkSkZNxnj1ZWpRfB53j8_IQAHOrLBO92JSEXi

2.4 Function Call 的混乱

Function Call 很棒,可以极大扩充 LLM 的能力范围,但是它有一个最大的缺陷:缺乏统一规范

标题OpenAIClaude
输入image.pngimage.png
输出image.pngimage.png

这个缺陷导致各种 tools 之间难以复用、生态割裂、哪怕就是想互相兼容都找不到一个公认标准...

3. 爆火的 MCP

官方文档:modelcontextprotocol.io/introductio…

在没有 MCP 之前,如果涉及到 Tools 部分:

身份需求痛点
AI 平台接入工具不同 LLM 的 tools 入参并不是一致的,市面上没有通用 tools 的入参标准,每接入一个工具,都需要阅读其文档并遵循其规范
工具提供方提供、推广工具没有可参照的提供工具标准,只能任选市面上一种 LLM 的规范来遵循,可能其他模型不能很好的调用,也不利于推广
用户创建智能体哪怕是同一个工具,在不同 AI 平台的工作流中由于接入方式不同,可能存在不同的调用、传参、异常处理逻辑

因此,MCP 协议诞生了!

MCP 是一个开放协议,它规范了应用程序如何向 LLM 提供上下文。可以将 MCP 想象成 AI 应用程序的 USB-C 接口。就像 USB-C 为设备连接各种外设和配件提供了标准化方式一样,MCP 为 AI 模型连接不同的数据源和工具提供了标准化方式

3.1 MCP 总体架构

我们可以先大致了解一下官网所介绍的「总体架构」

MCP 遵循客户端(Client)- 服务器(Server)架构,其中主机应用(Host)程序可以连接到多个服务器:

  • MCP 主机(Host):想要通过 MCP 访问数据的程序,如 Trae、Claude Desktop、IDE 或 AI 工具
  • MCP 客户端(Client):与服务器保持 1:1 连接的协议客户端
  • MCP 服务器(Server):通过标准化的模型上下文协议暴露特定功能的轻量级程序
  • 本地数据源:MCP 服务器可以安全访问的计算机文件、数据库和服务
  • 远程服务:MCP 服务器可以连接的互联网上的外部系统(例如,通过 API)

3.2 MCP 和 Function Call 的映射关系

我们先把 MCP 简单理解为一个目前被公认的 FunctionCall 规范

那么 MCP 和 Function Call 的映射关系如下

image.png

  • MCP Server - search、add 工具,提供之后可以被任意支持 MCP 协议的 host 调用

  • MCP Client - 一般由 Host 提供,用于连接 MCP Server

  • MCP Host - host 会与 client 交互判断当前应该使用那些工具、抹平不同 LLM 之间的 tools 参数差异

3.3 MCP 实现

3.3.1 MCP Server

假设我的目标是创建一个数学计算相关的 MCP,提供精确的加减法

先创建一个 tools 文件提供工具

    // 4_server/tools.js

    import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
    import { z } from "zod";

    const server = new McpServer({
      name: "math",
      version: "1.0.0",
    });

    server.tool(
      "add",
      "计算两个数字的和",
      {
        a: z.number().describe("First number"),
        b: z.number().describe("Second number"),
      },
      async ({ a, b }) => {
        return {
          content: [
            {
              type: "text",
              text: `${a} + ${b} = ${a + b}`,
            },
          ],
        };
      }
    );

    server.tool(
      "minus",
      "计算两个数字的差",
      {
        a: z.number().describe("First number"),
        b: z.number().describe("Second number"),
      },
      async ({ a, b }) => {
        return {
          content: [
            {
              type: "text",
              text: `${a} - ${b} = ${a - b}`,
            },
          ],
        };
      }
    );

    export default server;

有了工具之后,我们新建一个 transport 并与 Server 绑定

一个简单的 加减法 MCP 就被成功启动了!

    // 4_server/stdio.js

    import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
    import server from "./tools.js";

    const transport = new StdioServerTransport();
    await server.connect(transport);

可以通过 Trae 来验证

  • 配置 MCP 服务

  • 在对话中执行

  1. MCP Server SSE

Transports

MCP 定义了标准的 Server、Client 之间的消息格式,基于这个格式有不同的实现

  • 标准输入/输出 (stdio) - stdio 传输通过标准输入和输出流实现通信。这对于本地集成和命令行工具特别有用。

  • 服务器发送事件 (SSE) - 服务器发送事件 (SSE)

  • 自定义传输 - MCP 使实现自定义传输变得简单。任何传输实现只需要符合传输接口,目前官方的 @modelcontextprotocol/sdk 实际提供了多种实现

上一个例子是基于 stdio,我们可以再创建一个基于 SSE 的,以便于后续给 web 端的 client 使用

    // 4_server/sse.js

    import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
    import server from "./tools.js";
    import express from "express";

    let transport = null;

    const app = express();

    // 添加 CORS 中间件
    app.use((req, res, next) => {
      res.header("Access-Control-Allow-Origin", "*");
      res.header(
        "Access-Control-Allow-Headers",
        "Origin, X-Requested-With, Content-Type, Accept"
      );
      res.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
      next();
    });

    app.get("/sse", (req, res) => {
      transport = new SSEServerTransport("/messages", res);
      server.connect(transport);
    });

    app.post("/messages", (req, res) => {
      if (transport) {
        transport.handlePostMessage(req, res);
      }
    });

    app.listen(8083, () => {
      console.log("Server is running on port 8083");
    });

使用前先启动服务

node ./4_server/sse.js
  • 配置 MCP 服务

  • 在对话中执行

3.3.3 Inspector

如果没有现成的 Host,官方也提供了 debug 工具,可以直接设置参数来调用

npx @modelcontextprotocol/inspector node ./4_server/stdio.js

启动之后会生成一个本地服务 Web 页

image.png

工具目前支持 STDIO、SSE 两种 MCP Server,通过工具可以非常直观的验证 Tools 的调用结果以便于及时验证

3.3.4 MCP Client

如果你正在使用官方列出的 Host,那么只需要关注 Server 即可。Host 会根据你的配置自动适配对应的 Server 建立 1:1 的 Client

如果你希望自己实现一个 Host,或者想在 Web 端直接调用 MCP Client,过程如下:

    // 5_client/stdio.js

    import { Client } from "@modelcontextprotocol/sdk/client/index.js";
    import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

    const mcp = new Client({ name: "mcp-client", version: "1.0.0" });


    const transport = new StdioClientTransport({
      command: process.execPath,
      args: ["/Users/bytedance/my-project/trae/mcp_demo/4_server/stdio.js"],
    });

    await mcp.connect(transport);

    // 展示 Server 提供的 tools
    const listTools = await mcp.listTools();
    console.log(listTools);

    // 执行指定的 tool
    const result = await mcp.callTool({
      name: "add",
      arguments: { a: 1, b: 2 },
    });
    console.log(result);

    mcp.close();

与 Server 端工具连接(类似于 Trae 中的 mcp.json 配置)

    ❯ node ./5_client/stdio.js
    {
      tools: [
        { name: 'add', description: '计算两个数字的和', inputSchema: [Object] },
        { name: 'minus', description: '计算两个数字的差', inputSchema: [Object] }
      ]
    }
    { content: [ { type: 'text', text: '1 + 2 = 3' } ] }

可以清楚的看到,基于官方 SDK 来创建一个 Client 并不难。而且很容易发现一个有意思的点:

整个 MCP 里貌似没有 LLM 的存在

MCP 只是一个调用工具、注册工具的规范,哪怕不用大模型,我把市面上的 MCP Server 当做一个微服务来调用也是完全 OK 的

3.3.5 MCP Client SSE

同 Server 一样,Client 也有不同的实现,接下来我们基于 SSE 实现一个 web 版的 Client

    // 6_client_web/package.json

    {
      "name": "6_client_web",
      "version": "1.0.0",
      "main": "main.js",
      "scripts": {
        "dev": "vite"
      },
      "dependencies": {
        "@modelcontextprotocol/sdk": "^1.10.2",
        "vite": "^6.3.4"
      }
    }
    <!-- 6_client_web/index.html -->

    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
      </head>
      <body>
        <h1>MCP Client</h1>
        <div id="app"></div>
        <script type="module">
          import { Client } from "@modelcontextprotocol/sdk/client/index.js";
          import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";

          const mcp = new Client({
            name: "mcp-client-web",
            version: "1.0.0",
          });

          const transport = new SSEClientTransport(
            new URL("http://127.0.0.1:8083/sse")
          );

          await mcp.connect(transport);

          // 展示 Server 提供的 tools
          const listTools = await mcp.listTools();
          console.log(listTools);

          // 执行指定的 tool
          const result = await mcp.callTool({
            name: "add",
            arguments: { a: 1, b: 2 },
          });
          console.log(result);

          mcp.close();
        </script>
      </body>
    </html>

基于之前已经启动的 MCP Server SSE 服务,可以直接看到调用结果

3.3.6 MCP HOST

最后我们基于 vite 简单的实现一个 MCP Host,提供完整的使用链路

    // 7_host/package.json

    {
      "name": "7_host",
      "version": "1.0.0",
      "main": "main.js",
      "scripts": {
        "dev": "vite"
      },
      "dependencies": {
        "@modelcontextprotocol/sdk": "^1.10.2",
        "vite": "^6.3.4"
      }
    }
    <!-- 7_host/index.html -->

    <!DOCTYPE html>
    <html lang="en">

    <head>
      <meta charset="UTF-8" />
    </head>

    <body>
      <h1>MCP Host</h1>

      <div>
        Tools:
        <ul id="tools"></ul>
      </div>

      <label for="input">Query: <input type="text" id="input" /></label>

      <button id="button">Send</button>

      <div>Result:
        <div id="result"></div>
      </div>

      <script type="module">

        import { Client } from "@modelcontextprotocol/sdk/client/index.js";
        import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
        import { llmCall } from "../1_llm/index.js";

        const mcp = new Client({
          name: "mcp-client-web",
          version: "1.0.0",
        });

        const transport = new SSEClientTransport(
          new URL("http://127.0.0.1:8083/sse")
        );

        await mcp.connect(transport);

        // 展示 Server 提供的 tools
        const listTools = await mcp.listTools();;
        const tools = listTools.tools.map((tool) => {
          return `<li>${tool.name}</li>`;
        });

        document.getElementById("tools").innerHTML = tools.join("");

        document.getElementById("button").addEventListener("click", async (e) => {
          document.getElementById("result").innerHTML = "Loading...";
          const query = document.getElementById("input").value;
          const llmResult = await llmCall({
            messages: [{ role: "user", content: query }],
            tools: listTools.tools.map((tool) => {
              return {
                type: "function",
                function: {
                  name: tool.name,
                  description: tool.description,
                  parameters: tool.inputSchema,
                },
              };
            }),
          });

          if (llmResult.choices[0].message.tool_calls) {
            const toolCall = llmResult.choices[0].message.tool_calls[0];
            const toolName = toolCall.function.name;
            const toolArgs = JSON.parse(toolCall.function.arguments);
            const callResult = await mcp.callTool({
              name: toolName,
              arguments: toolArgs,
            });
            document.getElementById("result").innerHTML = JSON.stringify(callResult);
          }
        });
      </script>
    </body>

    </html>

4. 总结

4.1 MCP 的优势总结

通过上述对 LLM、Function Call 和 MCP 的详细介绍与实践,我们可以清晰地看到 MCP 在解决当前 AI 应用中 Tools 使用问题上的显著优势。

MCP 作为一个开放协议,为 AI 模型连接不同的数据源和工具提供了标准化方式,就像 USB - C 接口统一了设备连接外设的方式一样。它解决了 Function Call 缺乏统一规范所导致的各种问题,使得 AI 平台、工具提供方和用户都能从中受益。

对于 AI 平台来说,MCP 提供了通用的 tools 入参标准,大大降低了接入工具的成本,无需再为每个工具单独阅读和遵循其不同的规范。对于工具提供方,有了可参照的提供工具标准,能够让工具更容易被不同的 AI 模型调用,有利于工具的推广。而对于用户,在不同 AI 平台的工作流中,MCP 抹平了工具调用、传参和异常处理逻辑的差异,使得创建智能体变得更加简单。

另外,MCP 并不依赖于特定的大模型,它可以被当作一个微服务调用规范,即使不使用大模型,也能实现工具的调用和注册,具有很强的灵活性和通用性。

推荐阅读: