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

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

原文首发于公众号,欢迎关注获取更多 AI 开发实战干货。

为什么要学 MCP Server 开发?

MCP(Model Context Protocol)是当前 AI Agent 生态的事实标准。ChatGPT、Claude、Gemini、VS Code、Cursor 全部支持。你写一个 MCP Server,等于给所有主流 AI 都装上了一个新技能。

今天这篇文章,我带你从 npm init 开始,30 分钟内做出一个真正能用的 PDF 阅读 MCP Server------在 Claude 里说一句"帮我读一下这份 PDF 报告,总结一下核心观点",它就能自动调用你写的工具读取 PDF、提取文本、搜索关键内容,然后整理结果给你。

前置要求:会写 TypeScript,Node.js >= 20。

Step 1:项目初始化

bash 复制代码
mkdir mcp-pdf-reader && cd mcp-pdf-reader
npm init -y

安装依赖:

bash 复制代码
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

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,添加几个关键字段:

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"
  }
}

项目结构就一个文件:

css 复制代码
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,先写骨架:

typescript 复制代码
#!/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 是 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 客户端连接时,会问"你有什么工具?",这个处理器负责回答:

typescript 复制代码
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 就会乱调或者不调。

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

接下来注册"处理调用"的处理器------当 AI 决定调用某个工具时,这里负责执行:

typescript 复制代码
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}页):\n\n${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个)" : ""}:\n\n${display.join("\n")}${hasMore ? "\n\n...(还有更多结果)" : ""}`,
            }],
          };
        } 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. 前置校验:每个工具都先检查文件是否存在、格式是否正确。不要让错误在深层才暴露
  2. isError: true:告诉 AI "这个调用失败了"。AI 会根据错误信息决定是重试还是换策略
  3. 结果截断:搜索结果限制 10 条。MCP 返回的内容会占用 AI 的上下文窗口,返回太多会挤压 AI 的思考空间
  4. 错误信息要对人友好:这些错误文本是 AI 看的,它会直接转述给用户。写"文件不存在"比写"ENOENT"有用得多

构建:

bash 复制代码
npm run build

Step 5:调试和测试

MCP 官方提供了一个调试神器:MCP Inspector

bash 复制代码
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

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

Claude Code

bash 复制代码
claude mcp add pdf-reader node /你的绝对路径/mcp-pdf-reader/build/index.js

Cursor

在设置中找到 MCP,添加同样的配置。

接入后,你可以这样和 AI 对话:

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

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

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

typescript 复制代码
// ❌ 这会破坏 stdio 管道
console.log("Server started");

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

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

坑 2:ESM 和 CJS 模块混用

typescript 复制代码
// ❌ 在 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:配置文件用了相对路径

json 复制代码
// ❌ 不可靠
{ "args": ["./build/index.js"] }

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

坑 4:Tool 描述写得太笼统

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

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

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

开发阶段建议用 tsx 直接运行,免去构建步骤:

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

发布到 npm

package.json 里已经配好了 bin 字段,直接发:

bash 复制代码
npm publish --access public

发布后一行配置就能用:

json 复制代码
{
  "mcpServers": {
    "pdf-reader": {
      "command": "npx",
      "args": ["-y", "mcp-pdf-reader"]
    }
  }
}

完整代码

本文所有代码已开源:github.com/DonChengChe...

你学到了什么 关键点
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。


如果觉得有帮助,欢迎点赞收藏 👍

完整源码:github.com/DonChengChe...

更多 AI 开发实战文章,关注公众号「开发者效率局」,每周二/四/六更新。

相关推荐
想用offer打牌11 小时前
MCP (Model Context Protocol) 技术理解 - 第五篇
人工智能·后端·mcp
送梦想一个微笑25112 小时前
spring ai框架引入spring cloud alibaba2025.0.0后的修改
ai编程·mcp
用户48159301959113 小时前
深入理解Skill/MCP/RAG/Agent/OpenClaw底层逻辑
mcp
cahoho1 天前
构建Unity(团结引擎)MCP+Trae/Cursor生产线
mcp
想用offer打牌2 天前
MCP (Model Context Protocol) 技术理解 - 第四篇
后端·aigc·mcp
Yocn2 天前
Jadx-AI-MCP AI 帮我反编译!
claude·cursor·mcp
想用offer打牌2 天前
MCP (Model Context Protocol) 技术理解 - 第三篇
后端·aigc·mcp
Shawn_Shawn3 天前
mcp学习笔记(一)-mcp核心概念梳理
人工智能·llm·mcp
laplace01234 天前
mcp和skills区别
agent·rag·mcp·skills