手把手写一个 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:项目初始化
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);
});
几个关键点:
createRequire兼容处理:pdf-parse是 CJS 模块,ESM 项目直接import会报错。用createRequire桥接是标准做法,MCP 开发中经常遇到这类兼容问题capabilities: { tools: {} }:告诉 MCP 客户端"我提供 Tool 能力"。如果你还提供 Resource 或 Prompt,也在这里声明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 就会乱调或者不调。
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}页):\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,
};
}
});
代码不复杂,但有几个值得注意的模式:
- 前置校验:每个工具都先检查文件是否存在、格式是否正确。不要让错误在深层才暴露
isError: true:告诉 AI "这个调用失败了"。AI 会根据错误信息决定是重试还是换策略- 结果截断:搜索结果限制 10 条。MCP 返回的内容会占用 AI 的上下文窗口,返回太多会挤压 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:
{
"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
在设置中找到 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 消息,你往 stdout 写任何非 JSON-RPC 内容都会导致通信失败。
坑 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 直接运行,免去构建步骤:
{
"mcpServers": {
"pdf-reader": {
"command": "npx",
"args": ["tsx", "/path/to/mcp-pdf-reader/src/index.ts"]
}
}
}
发布到 npm
package.json 里已经配好了 bin 字段,直接发:
npm publish --access public
发布后一行配置就能用:
{
"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。
如果觉得有帮助,欢迎点赞收藏 👍
更多 AI 开发实战文章,关注公众号「开发者效率局」,每周二/四/六更新。