手把手写一个 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);
});
几个关键点:
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 客户端连接时,会问"你有什么工具?",这个处理器负责回答:
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,
};
}
});
代码不复杂,但有几个值得注意的模式:
- 前置校验:每个工具都先检查文件是否存在、格式是否正确。不要让错误在深层才暴露
isError: true:告诉 AI "这个调用失败了"。AI 会根据错误信息决定是重试还是换策略- 结果截断:搜索结果限制 10 条。MCP 返回的内容会占用 AI 的上下文窗口,返回太多会挤压 AI 的思考空间
- 错误信息要对人友好:这些错误文本是 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 开发实战文章,关注公众号「开发者效率局」,每周二/四/六更新。