30分钟快速上手:手把手教你从零搭建MCP Server实战教程
手把手写一个 MCP Server:从零到能用,只要 30 分钟(附完整源码)
在当前的 AI Agent 生态里,MCP(Model Context Protocol)已经成了事实上的标准协议。这意味着什么?简单来说,无论是 ChatGPT、Claude、Gemini,还是 VS Code、Cursor,它们都支持这个协议。所以,当你开发一个 MCP Server 时,你实际上是在为所有这些主流 AI 工具安装一个全新的、通用的“技能包”。
今天,我们就来动手实践一下。从最基础的 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);
});
这里有三个关键点需要特别注意:
createRequire兼容处理:由于pdf-parse是 CommonJS (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 正是依靠这个描述来决定在什么情况下调用哪个工具。描述如果模糊,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,
};
}
});
代码逻辑本身并不复杂,但其中体现的几个模式值得关注:
- 前置校验:每个工具都先检查文件是否存在、格式是否正确。不要让错误在深层逻辑中才暴露,这有助于 AI 快速理解问题。
isError: true:这个标志位至关重要,它明确告诉 AI “这次调用失败了”。AI 会根据返回的错误信息,决定是重试还是更换策略。- 结果截断:在搜索工具中,我们将结果限制在 10 条以内。因为 MCP 返回的内容会占用 AI 的上下文窗口,返回过多内容会挤压 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,添加你的 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 对你来说几乎没有任何障碍。
