30分钟快速上手:手把手教你从零搭建MCP Server实战教程

2026-05-28阅读 0热度 0
其他

手把手写一个 MCP Server:从零到能用,只要 30 分钟(附完整源码)

在当前的 AI Agent 生态里,MCP(Model Context Protocol)已经成了事实上的标准协议。这意味着什么?简单来说,无论是 ChatGPT、Claude、Gemini,还是 VS Code、Cursor,它们都支持这个协议。所以,当你开发一个 MCP Server 时,你实际上是在为所有这些主流 AI 工具安装一个全新的、通用的“技能包”。

手把手写一个 MCP Server:从零到能用,只要 30 分钟

今天,我们就来动手实践一下。从最基础的 npm init 开始,用不到 30 分钟的时间,构建一个真正能用的 PDF 阅读 MCP Server。完成后,你就能在 Claude 里直接说:“帮我读一下这份 PDF 报告,总结一下核心观点”,AI 便会自动调用你写的工具,完成读取、提取、搜索和整理等一系列操作。

开始之前,确保你具备两个基础条件:会写 TypeScript,并且 Node.js 版本在 20 或以上。

Step 1:项目初始化

首先,创建项目目录并初始化:

mkdir mcp-pdf-reader && cd mcp-pdf-reader
npm init -y

接着,安装必要的依赖包:

npm install @modelcontextprotocol/sdk pdf-parse
npm install -D typescript @types/node @types/pdf-parse

这里安装的三个核心包各有其职:

  • @modelcontextprotocol/sdk:MCP 协议的 TypeScript 官方实现。
  • pdf-parse:一个轻量级的 PDF 文件解析库,用于提取文本和元数据。
  • typescript:为项目提供类型安全。

然后,创建 tsconfig.json 配置文件:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "node",
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src/**/*"]
}

最后,更新 package.json,添加几个关键字段,特别是定义可执行入口和构建脚本:

{
  "type": "module",
  "bin": {
    "mcp-pdf-reader": "./build/index.js"
  },
  "scripts": {
    "build": "tsc && chmod +x build/index.js",
    "watch": "tsc --watch",
    "inspector": "npx @modelcontextprotocol/inspector build/index.js"
  }
}

整个项目的结构非常简洁,只有一个核心文件:

mcp-pdf-reader/
├── src/
│   └── index.ts  ← 全部代码都在这里
├── package.json
└── tsconfig.json

Step 2:创建 MCP Server 骨架

理解 MCP Server 的核心,其实只需要抓住三个基本概念:

原语 作用 类比
Tool 让 AI 执行具体操作 函数调用
Resource 为 AI 提供数据源 GET 接口
Prompt 预定义的提示模板 快捷指令

今天我们先聚焦在最常用、也最核心的 Tool 上。

创建 src/index.ts 文件,先搭建起服务器的基本骨架:

#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import fs from "fs";
import path from "path";

// pdf-parse 是 CJS 模块,在 ESM 项目中需要用 createRequire 加载
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const pdf = require("pdf-parse");

// 1. 创建 MCP Server 实例
const server = new Server(
  {
    name: "mcp-pdf-reader",
    version: "1.0.0",
  },
  {
    capabilities: {
      tools: {}, // 声明这个 Server 提供 Tool 能力
    },
  }
);

// 2. 这里注册工具(下一步实现)

// 3. 启动服务器
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("MCP PDF Reader server running on stdio");
}

main().catch((error) => {
  console.error("Server failed to start:", error);
  process.exit(1);
});

这里有三个关键点需要特别注意:

  1. createRequire 兼容处理:由于 pdf-parse 是 CommonJS (CJS) 模块,在 ESM 项目中直接 import 会报错。使用 createRequire 桥接是官方推荐的标准做法,这在 MCP 开发中经常会遇到。
  2. capabilities: { tools: {} }:这行代码是告诉 MCP 客户端:“我提供 Tool 能力”。如果你后续还想提供 Resource 或 Prompt,也需要在这里声明。
  3. console.error 用于日志:所有日志输出必须使用 console.error,因为 console.log 的输出会污染 stdio 通信管道,这是新手最容易踩的坑之一。

Step 3:注册工具——告诉 AI 你能做什么

在 MCP 中,注册一个工具需要两步:列出可用的工具,以及处理具体的调用请求。

首先,注册“列出工具”的处理器。当 AI 客户端连接时,它会询问“你有什么工具?”,下面的代码就是用来回答这个问题的:

server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "read_pdf",
        description: "Read and extract text content from a PDF file",
        inputSchema: {
          type: "object",
          properties: {
            file_path: {
              type: "string",
              description: "Path to the PDF file to read",
            },
          },
          required: ["file_path"],
        },
      },
      {
        name: "get_pdf_info",
        description: "Get metadata information from a PDF file (title, author, pages, etc.)",
        inputSchema: {
          type: "object",
          properties: {
            file_path: {
              type: "string",
              description: "Path to the PDF file to analyze",
            },
          },
          required: ["file_path"],
        },
      },
      {
        name: "search_in_pdf",
        description: "Search for specific text within a PDF file",
        inputSchema: {
          type: "object",
          properties: {
            file_path: {
              type: "string",
              description: "Path to the PDF file to search in",
            },
            search_text: {
              type: "string",
              description: "Text to search for in the PDF",
            },
            case_sensitive: {
              type: "boolean",
              description: "Whether the search should be case sensitive",
              default: false,
            },
          },
          required: ["file_path", "search_text"],
        },
      },
    ],
  };
});

我们定义了三个工具,基本覆盖了 PDF 处理的常见场景:

工具 功能 使用场景
read_pdf 提取全文文本 “帮我读一下这份报告”
get_pdf_info 获取元数据 “这个 PDF 多少页?谁写的?”
search_in_pdf 全文搜索 “找一下报告里提到‘营收’的地方”

这里有个细节很重要:description 字段要写得具体明确。Claude 等 AI 正是依靠这个描述来决定在什么情况下调用哪个工具。描述如果模糊,AI 就可能调用错误,或者干脆不调用。

Step 4:实现工具逻辑——AI 调用时实际执行什么

接下来,注册“处理调用”的处理器。当 AI 决定调用某个工具时,下面的代码就是实际执行的逻辑:

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  try {
    const { name, arguments: args } = request.params;

    switch (name) {
      case "read_pdf": {
        const { file_path } = args as { file_path: string };
        // 前置校验:文件是否存在、是否是 PDF
        if (!fs.existsSync(file_path)) {
          return {
            content: [{ type: "text", text: `错误: 文件 ${file_path} 不存在` }],
          };
        }
        if (!file_path.toLowerCase().endsWith(".pdf")) {
          return {
            content: [{ type: "text", text: `错误: ${file_path} 不是PDF文件` }],
          };
        }

        try {
          const dataBuffer = fs.readFileSync(file_path);
          const data = await pdf(dataBuffer);
          return {
            content: [{
              type: "text",
              text: `PDF文件内容 (${data.numpages}页):nn${data.text}`,
            }],
          };
        } catch (error) {
          return {
            content: [{
              type: "text",
              text: `读取PDF文件时出错: ${error instanceof Error ? error.message : String(error)}`,
            }],
            isError: true,
          };
        }
      }

      case "get_pdf_info": {
        const { file_path } = args as { file_path: string };
        if (!fs.existsSync(file_path)) {
          return {
            content: [{ type: "text", text: `错误: 文件 ${file_path} 不存在` }],
          };
        }

        try {
          const dataBuffer = fs.readFileSync(file_path);
          const data = await pdf(dataBuffer);
          const info = {
            文件路径: file_path,
            文件名: path.basename(file_path),
            文件大小: `${(dataBuffer.length / 1024 / 1024).toFixed(2)} MB`,
            页数: data.numpages,
            标题: data.info?.Title || "未知",
            作者: data.info?.Author || "未知",
            创建者: data.info?.Creator || "未知",
            创建日期: data.info?.CreationDate || "未知",
            文本字符数: data.text.length,
          };
          return {
            content: [{
              type: "text",
              text: `PDF文件信息:n${JSON.stringify(info, null, 2)}`,
            }],
          };
        } catch (error) {
          return {
            content: [{
              type: "text",
              text: `获取PDF信息时出错: ${error instanceof Error ? error.message : String(error)}`,
            }],
            isError: true,
          };
        }
      }

      case "search_in_pdf": {
        const { file_path, search_text, case_sensitive = false } = args as {
          file_path: string;
          search_text: string;
          case_sensitive?: boolean;
        };
        if (!fs.existsSync(file_path)) {
          return {
            content: [{ type: "text", text: `错误: 文件 ${file_path} 不存在` }],
          };
        }

        try {
          const dataBuffer = fs.readFileSync(file_path);
          const data = await pdf(dataBuffer);
          const lines = data.text.split("n");
          const matches: string[] = [];

          lines.forEach((line: string, index: number) => {
            const lineToCheck = case_sensitive ? line : line.toLowerCase();
            const searchTerm = case_sensitive ? search_text : search_text.toLowerCase();
            if (lineToCheck.includes(searchTerm)) {
              matches.push(`第${index + 1}行: ${line.trim()}`);
            }
          });

          if (matches.length === 0) {
            return {
              content: [{
                type: "text",
                text: `在 ${path.basename(file_path)} 中未找到 "${search_text}"`,
              }],
            };
          }

          // 限制显示前 10 个,避免输出过长
          const display = matches.slice(0, 10);
          const hasMore = matches.length > 10;
          return {
            content: [{
              type: "text",
              text: `找到 ${matches.length} 个匹配项${hasMore ? " (显示前10个)" : ""}:nn${display.join("n")}${hasMore ? "nn...(还有更多结果)" : ""}`,
            }],
          };
        } catch (error) {
          return {
            content: [{
              type: "text",
              text: `搜索PDF内容时出错: ${error instanceof Error ? error.message : String(error)}`,
            }],
            isError: true,
          };
        }
      }

      default:
        return {
          content: [{ type: "text", text: `未知工具: ${name}` }],
          isError: true,
        };
    }
  } catch (error) {
    return {
      content: [{
        type: "text",
        text: `执行工具时发生错误: ${error instanceof Error ? error.message : String(error)}`,
      }],
      isError: true,
    };
  }
});

代码逻辑本身并不复杂,但其中体现的几个模式值得关注:

  1. 前置校验:每个工具都先检查文件是否存在、格式是否正确。不要让错误在深层逻辑中才暴露,这有助于 AI 快速理解问题。
  2. isError: true:这个标志位至关重要,它明确告诉 AI “这次调用失败了”。AI 会根据返回的错误信息,决定是重试还是更换策略。
  3. 结果截断:在搜索工具中,我们将结果限制在 10 条以内。因为 MCP 返回的内容会占用 AI 的上下文窗口,返回过多内容会挤压 AI 的思考空间。
  4. 友好的错误信息:错误信息是给 AI 看的,而 AI 会直接转述给用户。因此,写“文件不存在”远比写一个晦涩的“ENOENT”错误码要有用得多。

代码写完后,执行构建命令:

npm run build

Step 5:调试和测试

MCP 官方提供了一个非常实用的调试工具:MCP Inspector。运行以下命令启动它:

npx @modelcontextprotocol/inspector build/index.js

浏览器会自动打开 http://localhost:6274。在这个界面里,你可以:

  • 看到所有已注册的 Tools。
  • 手动填写参数来测试每个 Tool。
  • 实时查看请求和响应的原始 JSON 数据。
  • 检查错误信息是否准确。

Step 6:接入 AI 客户端

Claude Desktop

编辑配置文件 ~/Library/Application Support/Claude/claude_desktop_config.json,添加你的 Server:

{
  "mcpServers": {
    "pdf-reader": {
      "command": "node",
      "args": ["/你的绝对路径/mcp-pdf-reader/build/index.js"]
    }
  }
}

Claude Code

在终端中直接使用命令行添加:

claude mcp add pdf-reader node /你的绝对路径/mcp-pdf-reader/build/index.js

Cursor

在 Cursor 的设置中找到 MCP 配置项,添加与上面类似的配置即可。

配置完成后,你就可以这样和 AI 对话了:

  • “帮我读一下 ~/Documents/report.pdf,总结核心观点”
  • “这份 PDF 有多少页?作者是谁?”
  • “在这份 PDF 里搜一下‘营收增长’相关的内容”

踩坑指南:5 个最常见的错误

坑 1:console.log 导致通信失败

// ❌ 这会破坏 stdio 管道
console.log("Server started");

// ✅ 所有日志用 stderr
console.error("Server started");

MCP 通过 stdout 传输 JSON-RPC 消息,任何非 JSON-RPC 内容写入 stdout 都会导致通信失败。

坑 2:ESM 和 CJS 模块混用

// ❌ 在 ESM 项目中直接 import CJS 模块会报错
import pdf from "pdf-parse";

// ✅ 用 createRequire 桥接
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const pdf = require("pdf-parse");

目前仍有大量 Node.js 库尚未迁移到 ESM 格式。createRequire 是官方推荐的兼容方案。

坑 3:配置文件用了相对路径

// ❌ 不可靠
{ "args": ["./build/index.js"] }

// ✅ 绝对路径
{ "args": ["/Users/you/projects/mcp-pdf-reader/build/index.js"] }

坑 4:Tool 描述写得太笼统

// ❌ AI 不知道什么时候该调用你
{ name: "read", description: "读取文件" }

// ✅ 明确描述
{ name: "read_pdf", description: "Read and extract text content from a PDF file" }

坑 5:修改代码后忘了重新构建

在开发阶段,建议使用 tsx 直接运行 TypeScript 源码,省去手动构建的步骤。在配置文件中可以这样设置:

{
  "mcpServers": {
    "pdf-reader": {
      "command": "npx",
      "args": ["tsx", "/path/to/mcp-pdf-reader/src/index.ts"]
    }
  }
}

发布到 npm

我们的 package.json 里已经配置好了 bin 字段,发布到 npm 非常简单:

npm publish --access public

发布之后,别人只需要一行配置就能使用你的工具:

{
  "mcpServers": {
    "pdf-reader": {
      "command": "npx",
      "args": ["-y", "mcp-pdf-reader"]
    }
  }
}

完整代码

本文涉及的所有完整代码均已开源。

你学到了什么 关键点
MCP Server 基础架构 Server + StdioTransport + capabilities 声明
Tool 注册 ListToolsRequestSchema 列出 + CallToolRequestSchema 处理
输入输出规范 inputSchema 定义参数,content + isError 返回结果
ESM 兼容 createRequire 桥接 CJS 模块
调试方法 MCP Inspector(localhost:6274)
接入 AI 客户端 claude_desktop_config.json / claude mcp add

整个流程的学习曲线其实非常平缓。如果你熟悉如何写一个 Express 路由,那么编写 MCP Server 对你来说几乎没有任何障碍。

免责声明

本网站新闻资讯均来自公开渠道,力求准确但不保证绝对无误,内容观点仅代表作者本人,与本站无关。若涉及侵权,请联系我们处理。本站保留对声明的修改权,最终解释权归本站所有。

相关阅读

更多
欢迎回来 登录或注册后,可保存提示词和历史记录
登录后可同步收藏、历史记录和常用模板
注册即表示同意服务条款与隐私政策