教程·阅读约 13 分钟·
MCP Server 从零搭建:用 TypeScript 为 AI Agent 构建自定义工具生态

MCP Server 从零搭建:用 TypeScript 为 AI Agent 构建自定义工具生态

手把手教你搭建 MCP Server——从项目初始化、工具注册到部署运行,让 AI Agent 通过 MCP 协议调用任意外部工具

四月·

原创。MCP Server 不是一个黑箱——你完全可以用 TypeScript 从零搭建一个生产级的 MCP 服务器,让任何支持 MCP 协议的 AI Agent(Claude Code、Cursor、Windsurf 等)都能通过标准化接口调用你的自定义工具。

MCP(Model Context Protocol)是 Anthropic 在 2024 年底提出的开放协议,目标是给 AI 应用提供一个标准化的外部工具接入接口。如果你已经在用 Claude Code 或者 Cursor 这类 AI 编程工具,很可能已经接触过 MCP——比如通过社区维护的 MCP 服务器查询数据库、搜索文档、操作 GitHub。

但如果你需要的工具社区里没有现成的,或者公司内部系统暴露的能力无法通过公开 MCP 服务器完成,那就需要自己动手写一个。

本文从零开始,用 TypeScript 搭建一个功能完整的 MCP Server,包含文件操作、Web 搜索、数据库查询三个实战工具,然后接入 Claude Code 和 Cursor 验证效果。读完你就能自己写任意自定义 MCP 工具。

一、MCP 协议的核心模型

动手之前,先搞清楚 MCP 在协议层面做了什么,这样后面写代码时每一步都知道自己在干什么。

1.1 通信架构

MCP 采用 客户端—服务器 架构:

code
AI Agent (Host)
  └── MCP Client (协议客户端)
        └── [传输层: stdio 或 HTTP/SSE]
              └── MCP Server (提供 Tools / Resources / Prompts)
  • Host:用户直接使用的 AI 应用,如 Claude Code、Cursor、Claude Desktop
  • Client:Host 内部为每个 MCP Server 维护的一个协议客户端实例
  • Server:你写的这个程序,暴露特定能力给 AI Agent 调用

通信协议基于 JSON-RPC 2.0,也就是客户端发 JSON 格式的请求,服务器回 JSON 格式的响应。每个请求有个 id,响应通过同样的 id 关联。

1.2 三种核心能力

MCP Server 可以提供三类能力:

能力说明典型用途
Tools可被 LLM 调用的函数查询天气、写文件、发邮件
Resources只读数据源,用户用 @ 引用读取文档、查看日志
Prompts预置提示模板"/code-review" 等固定指令

对于绝大部分需求场景,Tools 是最常用的。本文也以 Tools 为主线展开。

1.3 能力协商生命周期

每次连接建立时,客户端和服务端先做一次握手:

  1. 客户端发送 initialize 请求,声明支持的协议版本和能力
  2. 服务器回复 initialize 响应,说明自己支持的工具、资源、提示等能力
  3. 客户端发送 notifications/initialized 通知,确认初始化完成
  4. 客户端调用 tools/list 获取服务器上的工具列表
  5. 客户端调用 tools/call 执行具体工具

这个过程是自动的——你不需要手动处理任何 JSON-RPC 消息。@modelcontextprotocol/sdk 会帮你搞定这一切。

二、什么时候该写一个 MCP Server?

这是我被问得最多的问题。决策依据很简单:

应该写 MCP Server 的场景:

  • 工具逻辑复杂,状态需要维护(数据库连接池、缓存等)
  • 工具需要安全隔离(运行在独立进程中,崩溃不影响 Host)
  • 工具需要被多个不同的 AI Agent 复用
  • 工具依赖特定运行时环境(Node.js、Python、Go 各写各的也没问题)
  • 需要权限控制、认证和审计

直接实现工具更合适的场景:

  • 简单的一行计算 1 + 1 = ?
  • 只在一个 Agent 内部使用的调试辅助函数
  • 读取当前工作目录下的某个文件

有一个很好的经验法则:如果这个工具有可能被其他项目复用,或者需要独立的生命周期管理,就做成 MCP Server。

三、项目初始化与依赖安装

开始写代码。我的项目结构是这样的:

code
mcp-server-demo/
├── package.json
├── tsconfig.json
├── src/
│   ├── index.ts          # 入口
│   ├── tools/
│   │   ├── index.ts      # 工具汇总
│   │   ├── file-tools.ts # 文件操作工具
│   │   ├── search-tools.ts # 搜索工具
│   │   └── db-tools.ts   # 数据库查询工具
│   └── services/
│       ├── search.ts     # 搜索引擎封装
│       └── db.ts         # 数据库连接封装

3.1 初始化项目

code
mkdir mcp-server-demo && cd mcp-server-demo
npm init -y

3.2 安装核心依赖

code
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx

解释每个包的作用:

  • @modelcontextprotocol/sdk:Anthropic 官方的 MCP 协议 SDK,封装了 JSON-RPC 通信、传输层、工具注册等全部底层逻辑。你不需要手动处理任何协议细节
  • zod:TypeScript-first 的 schema 验证库。MCP 协议的 Tool 入参描述格式和 zod 天然契合,用来定义每个工具的输入参数及其校验规则
  • typescript + @types/node:TypeScript 编译器和 Node.js 类型定义
  • tsx:开发模式下直接运行 TypeScript 文件的工具,免去每次修改都编译一遍的麻烦

3.3 TypeScript 配置

code
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "esModuleInterop": true,
    "strict": true,
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

关键点:module: "NodeNext" 配合 moduleResolution: "NodeNext" 让 Node.js 能正确解析 ESM 模块——这是 @modelcontextprotocol/sdk 的要求。

3.4 package.json 添加脚本

code
{
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsx watch src/index.ts",
    "inspect": "npx @modelcontextprotocol/inspector node dist/index.js"
  },
  "type": "module"
}

"type": "module" 很重要——MCP SDK 是 ESM-only 的包,你的项目必须用 ESM 模式。

四、Core Concepts:用 SDK 搭建骨架

SDK 的思路很清晰:你创建一个 Server 实例,在上面注册工具、资源和提示模板,然后通过一个传输层(Transport)暴露出去。

4.1 创建 Server 实例

code
// src/index.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
 
const server = new Server(
  {
    name: "mcp-server-demo",
    version: "1.0.0",
  },
  {
    capabilities: {
      tools: {},
    },
  }
);

Server 构造函数接收两个参数:

  1. ServerInfo:服务器的名称和版本。AI Agent 在连接后会发现这个信息
  2. ServerCapabilities:声明服务器支持哪些能力。tools: {} 表示支持工具调用,这里是空的配置对象,后续可以传 listChanged: true 表示支持工具列表动态变化

4.2 传输层:Stdio vs SSE vs HTTP

MCP 定义了几种传输方式:

StdioTransport(默认):通过标准输入输出通信。AI Agent 把 MCP Server 作为子进程启动,通过 stdin/stdout 交换 JSON-RPC 消息。这是本地开发最常用的方式,也是 Claude Code 和 Cursor 默认的接入方式。

code
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
 
const transport = new StdioServerTransport();
await server.connect(transport);

SSETransport(Server-Sent Events):服务器通过 HTTP 暴露 SSE 端点,客户端通过 POST 请求发送消息。适合远程服务器场景。

Streamable HTTP:MCP 协议较新的传输方式,使用 HTTP POST + SSE 流响应。Claude Code 从 2025-06-18 协议版本开始推荐使用。

code
// SSE 传输示例
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express from "express";
 
const app = express();
const transports: Map<string, SSEServerTransport> = new Map();
 
app.get("/sse", (req, res) => {
  const transport = new SSEServerTransport("/messages", res);
  transports.set(transport.sessionId, transport);
  server.connect(transport);
 
  res.on("close", () => {
    transports.delete(transport.sessionId);
  });
});
 
app.post("/messages", (req, res) => {
  const sessionId = req.query.sessionId as string;
  const transport = transports.get(sessionId);
  if (transport) {
    transport.handlePostMessage(req, res);
  }
});

对于本地开发,Stdio 是最简单的选择。对于生产部署,SSE 或 HTTP 更合适。

4.3 注册第一个工具

工具注册的核心 API 是 server.setRequestHandler

code
// 处理 tools/list 请求:返回可用工具列表
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "hello_world",
        description: "一个简单的问候工具",
        inputSchema: {
          type: "object",
          properties: {
            name: {
              type: "string",
              description: "你的名字",
            },
          },
          required: ["name"],
        },
      },
    ],
  };
});
 
// 处理 tools/call 请求:执行具体的工具逻辑
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;
 
  if (name === "hello_world") {
    return {
      content: [
        {
          type: "text",
          text: `你好, ${args.name}! 欢迎使用 MCP Server.`,
        },
      ],
    };
  }
 
  throw new Error(`未知的工具: ${name}`);
});

每个工具定义包含:

  • name:工具名称,AI Agent 通过这个名称调用
  • description:工具描述,LLM 用这个描述判断什么时候该调用此工具。写得越清晰准确,模型调用越精准
  • inputSchema:JSON Schema 格式的入参描述。这是 LLM Function Calling 的接口定义,字段名和描述决定了模型能否正确填充参数

工具的返回结果是一个 content 数组,每个元素可以是:

  • { type: "text", text: "..." }:文本响应
  • { type: "resource", resource: { uri, mimeType, text } }:资源引用

五、实战工具一:文件操作

先从最实用的文件系统操作开始——让 AI Agent 能读取、写入和列出服务器上的文件。

5.1 用 zod 定义输入 schema

直接手写 JSON Schema 很繁琐且容易出错。利用 zod 的类型推断配合 zod-to-json-schema,可以优雅地定义工具输入:

code
npm install zod-to-json-schema
code
// src/tools/file-tools.ts
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import fs from "node:fs/promises";
import path from "node:path";
import type { Tool } from "../types.js";
 
const ReadFileSchema = z.object({
  path: z.string().describe("文件绝对路径"),
});
 
const WriteFileSchema = z.object({
  path: z.string().describe("文件绝对路径"),
  content: z.string().describe("文件内容"),
});
 
const ListDirSchema = z.object({
  path: z.string().describe("目录绝对路径"),
});
 
export const readFileTool: Tool = {
  name: "read_file",
  description: "读取指定路径文件的全部内容",
  inputSchema: zodToJsonSchema(ReadFileSchema),
  handler: async (args) => {
    const { path: filePath } = ReadFileSchema.parse(args);
    const content = await fs.readFile(filePath, "utf-8");
    return {
      content: [
        {
          type: "text",
          text: content,
        },
      ],
    };
  },
};
 
export const writeFileTool: Tool = {
  name: "write_file",
  description: "向指定路径写入文件内容(覆盖写入)",
  inputSchema: zodToJsonSchema(WriteFileSchema),
  handler: async (args) => {
    const { path: filePath, content } = WriteFileSchema.parse(args);
    await fs.mkdir(path.dirname(filePath), { recursive: true });
    await fs.writeFile(filePath, content, "utf-8");
    return {
      content: [
        {
          type: "text",
          text: `文件已写入: ${filePath} (${content.length} 字符)`,
        },
      ],
    };
  },
};
 
export const listDirTool: Tool = {
  name: "list_directory",
  description: "列出指定目录下的所有文件和子目录",
  inputSchema: zodToJsonSchema(ListDirSchema),
  handler: async (args) => {
    const { path: dirPath } = ListDirSchema.parse(args);
    const entries = await fs.readdir(dirPath, { withFileTypes: true });
    const listing = entries.map((entry) => {
      return `${entry.isDirectory() ? "[DIR]" : "[FILE]"} ${entry.name}`;
    });
    return {
      content: [
        {
          type: "text",
          text: listing.join("\n"),
        },
      ],
    };
  },
};

5.2 出错不可怕,关键在优雅处理

MCP 协议的 Error Handling 设计很简洁:工具内抛出的错误会被 SDK 自动捕获并包装为 JSON-RPC 错误响应返回给客户端。但你也可以自定义错误信息:

code
handler: async (args) => {
  try {
    const { path: filePath } = ReadFileSchema.parse(args);
    const content = await fs.readFile(filePath, "utf-8");
    return { content: [{ type: "text", text: content }] };
  } catch (error) {
    if (error instanceof z.ZodError) {
      return {
        content: [{ type: "text", text: `参数校验失败: ${error.message}` }],
        isError: true,
      };
    }
    if ((error as NodeJS.ErrnoException).code === "ENOENT") {
      return {
        content: [{ type: "text", text: `文件不存在: ${filePath}` }],
        isError: true,
      };
    }
    throw error;
  }
};

注意这里的 isError: true——这是 MCP 协议的标准做法。返回 isError: true 的响应,AI Agent 会收到明确的错误信号,而不是把错误文本当成正常输出。

六、实战工具二:Web 搜索

第二个工具让 AI Agent 能搜索互联网。这里以 SerpAPI 为例(也可以用 Bing Search API、Tavily 等):

code
// src/services/search.ts
const SERPAPI_KEY = process.env.SERPAPI_KEY;
 
export async function searchWeb(query: string, num: number = 5) {
  const params = new URLSearchParams({
    q: query,
    api_key: SERPAPI_KEY!,
    num: String(num),
    engine: "google",
  });
 
  const response = await fetch(
    `https://serpapi.com/search?${params.toString()}`
  );
 
  if (!response.ok) {
    throw new Error(`SerpAPI 请求失败: ${response.statusText}`);
  }
 
  const data = await response.json();
  return data.organic_results.map((r: any) => ({
    title: r.title,
    link: r.link,
    snippet: r.snippet,
  }));
}
code
// src/tools/search-tools.ts
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import { searchWeb } from "../services/search.js";
import type { Tool } from "../types.js";
 
const SearchSchema = z.object({
  query: z.string().min(1).describe("搜索关键词"),
  max_results: z.number().min(1).max(20).default(5).describe("返回结果数量"),
});
 
export const webSearchTool: Tool = {
  name: "web_search",
  description:
    "搜索互联网信息,返回标题、链接和摘要。当需要获取实时信息、最新新闻或你不知道的内容时使用此工具",
  inputSchema: zodToJsonSchema(SearchSchema),
  handler: async (args) => {
    const { query, max_results } = SearchSchema.parse(args);
    const results = await searchWeb(query, max_results);
 
    if (results.length === 0) {
      return {
        content: [{ type: "text", text: "未找到相关搜索结果" }],
      };
    }
 
    const formatted = results
      .map(
        (r, i) =>
          `${i + 1}. [${r.title}](${r.link})\n   ${r.snippet}`
      )
      .join("\n\n");
 
    return {
      content: [
        {
          type: "text",
          text: `搜索 "${query}" 的结果:\n\n${formatted}`,
        },
      ],
    };
  },
};

这里有个容易被忽略的细节:工具的描述文本对 LLM 的调用决策影响非常大。比如 web_search 的描述里写了"当需要获取实时信息、最新新闻或你不知道的内容时使用此工具",模型就会在遇到时效性问题时优先选择这个工具而不是依赖自己的训练数据。

七、实战工具三:数据库查询

第三个工具是数据库查询。这里以 SQLite 为例——因为不需要额外安装数据库服务器,适合演示:

code
npm install better-sqlite3
npm install -D @types/better-sqlite3
code
// src/tools/db-tools.ts
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import Database from "better-sqlite3";
import type { Tool } from "../types.js";
 
const db = new Database("mcp_demo.db");
 
// 初始化演示表
db.exec(`
  CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    email TEXT UNIQUE NOT NULL,
    created_at TEXT DEFAULT (datetime('now'))
  )
`);
 
const QuerySchema = z.object({
  sql: z.string().describe("SQL 查询语句,仅支持 SELECT"),
});
 
const ExecuteSchema = z.object({
  sql: z.string().describe("SQL 执行语句,仅支持 INSERT/UPDATE/DELETE"),
  params: z
    .array(z.union([z.string(), z.number(), z.null()]))
    .optional()
    .describe("SQL 参数(防 SQL 注入)"),
});
 
export const dbQueryTool: Tool = {
  name: "db_query",
  description: "对 SQLite 数据库执行 SELECT 查询,返回结果数组",
  inputSchema: zodToJsonSchema(QuerySchema),
  handler: async (args) => {
    const { sql } = QuerySchema.parse(args);
    const normalized = sql.trim().toUpperCase();
 
    if (!normalized.startsWith("SELECT")) {
      return {
        content: [{ type: "text", text: "错误:db_query 仅支持 SELECT 查询" }],
        isError: true,
      };
    }
 
    const rows = db.prepare(sql).all();
    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(rows, null, 2),
        },
      ],
    };
  },
};
 
export const dbExecuteTool: Tool = {
  name: "db_execute",
  description: "对 SQLite 数据库执行 INSERT、UPDATE、DELETE 操作",
  inputSchema: zodToJsonSchema(ExecuteSchema),
  handler: async (args) => {
    const { sql, params = [] } = ExecuteSchema.parse(args);
    const normalized = sql.trim().toUpperCase();
 
    if (
      !normalized.startsWith("INSERT") &&
      !normalized.startsWith("UPDATE") &&
      !normalized.startsWith("DELETE")
    ) {
      return {
        content: [
          { type: "text", text: "错误:db_execute 仅支持 INSERT/UPDATE/DELETE" },
        ],
        isError: true,
      };
    }
 
    const result = db.prepare(sql).run(...params);
    return {
      content: [
        {
          type: "text",
          text: `操作成功。影响行数: ${result.changes}`,
        },
      ],
    };
  },
};

安全注意点:

  1. SQL 类型隔离:把查询和执行分开成两个工具,在 handlers 里分别做 SQL 语句前缀校验
  2. 参数化查询:虽然 params 不是强制的,但在工具描述里明确告知模型应该使用参数化查询
  3. DDL 防护:没有提供 DROP TABLEALTER TABLE 的能力——这些应该是运维操作,不是 AI Agent 调用的工具

八、整合所有工具到服务器

有了单个工具定义后,把它们聚合到主入口文件:

code
// src/types.ts
import { z } from "zod";
import type {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
 
export interface Tool {
  name: string;
  description: string;
  inputSchema: Record<string, unknown>;
  handler: (args: Record<string, unknown>) => Promise<{
    content: Array<{ type: "text"; text: string }>;
    isError?: boolean;
  }>;
}
code
// src/tools/index.ts
import type { Tool } from "../types.js";
import { readFileTool, writeFileTool, listDirTool } from "./file-tools.js";
import { webSearchTool } from "./search-tools.js";
import { dbQueryTool, dbExecuteTool } from "./db-tools.js";
 
export const tools: Tool[] = [
  readFileTool,
  writeFileTool,
  listDirTool,
  webSearchTool,
  dbQueryTool,
  dbExecuteTool,
];
code
// src/index.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  ListToolsRequestSchema,
  CallToolRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { tools } from "./tools/index.js";
 
const server = new Server(
  {
    name: "mcp-server-demo",
    version: "1.0.0",
  },
  {
    capabilities: {
      tools: {},
    },
  }
);
 
// 处理工具列表请求
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: tools.map((t) => ({
      name: t.name,
      description: t.description,
      inputSchema: t.inputSchema,
    })),
  };
});
 
// 处理工具调用请求
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;
 
  const tool = tools.find((t) => t.name === name);
  if (!tool) {
    throw new Error(`未找到工具: ${name}`);
  }
 
  return tool.handler(args || {});
});
 
// 启动服务器
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP Server 已启动 (transport: stdio)");

注意这里用 console.error 而不是 console.log 输出日志——因为 Stdio 模式下,stdout 是 MCP 协议消息的通道,日志信息只能走 stderr,否则会污染 JSON-RPC 消息流。这是一个非常容易踩的坑。

九、用 MCP Inspector 调试服务器

写 MCP Server 最痛苦的事情之一就是调试。看不到请求和响应,不知道模型传了什么参数回来,也不知道自己的 handler 返回了什么格式的数据。

MCP Inspector 是官方提供的调试工具,可视化地展示所有通信细节:

code
npx @modelcontextprotocol/inspector node dist/index.js

启动后在浏览器中打开 Inspector 提供的地址(通常是 http://localhost:5173),你会看到:

  1. 工具列表:自动调用 tools/list 并展示你注册的所有工具及其 schema
  2. 工具调用模拟:手动输入参数调用任意工具,查看返回结果
  3. 请求日志:完整的 JSON-RPC 请求/响应记录,每个字段都能展开查看

开发工作流建议:

code
修改代码 → 重新编译 → 重启 Inspector → 测试 → 循环

tsx watch 模式配合 Inspector 会更高效:

code
# 终端 1:监视模式自动重启
tsx watch --inspect src/index.ts
 
# 终端 2:启动 Inspector 连接到上述进程
npx @modelcontextprotocol/inspector

十、连接 AI Agent:Claude Code 和 Cursor

10.1 Claude Code

在 Claude Code 的项目根目录下创建 .claude/settings.json

code
{
  "mcpServers": {
    "mcp-server-demo": {
      "command": "node",
      "args": ["/absolute/path/to/mcp-server-demo/dist/index.js"]
    }
  }
}

或者如果配置了 npx 快捷方式:

code
{
  "mcpServers": {
    "mcp-server-demo": {
      "command": "npx",
      "args": [
        "-y",
        "@your-org/mcp-server-demo"
      ]
    }
  }
}

重启 Claude Code,在聊天中输入 "帮我看看当前目录下的 src 文件夹里有什么文件"。Claude Code 会自动调用 list_directory 工具。

10.2 Cursor

Cursor 在 Settings > Features > MCP Servers 中管理:

code
Name:   mcp-server-demo
Type:   command
Command: node /absolute/path/to/mcp-server-demo/dist/index.js

或者通过项目目录下的 .cursor/mcp.json

code
{
  "mcpServers": {
    "mcp-server-demo": {
      "command": "node",
      "args": ["/absolute/path/to/mcp-server-demo/dist/index.js"]
    }
  }
}

10.3 Windsurf / Claude Desktop

Windsurf 同样支持 MCP——在 ~/.codeium/windsurf/mcp_config.json 中配置:

code
{
  "mcpServers": {
    "mcp-server-demo": {
      "command": "node",
      "args": ["/absolute/path/to/mcp-server-demo/dist/index.js"]
    }
  }
}

Claude Desktop 则在 claude_desktop_config.json 中配置,格式和 Claude Code 完全一致。

十一、生产环境注意事项

本地跑通只是第一步。如果要把 MCP Server 部署到生产环境,有几个关键问题要考虑。

11.1 安全性

输入校验是最后一道防线。永远不要在 handler 里相信 LLM 传过来的参数——模型可能在你的描述写得很清楚的情况下传错值,也可能被 prompt injection 利用。

code
// 错误示范:直接信任参数
handler: async (args) => {
  const filePath = args.path as string; // ❌ 可能有路径穿越攻击
  const content = await fs.readFile(filePath, "utf-8");
  // ...
}
 
// 正确做法:校验白名单
const ALLOWED_BASE_DIRS = ["/home/projects", "/tmp/mcp"];
 
function isPathSafe(filePath: string): boolean {
  const resolved = path.resolve(filePath);
  return ALLOWED_BASE_DIRS.some((dir) =>
    resolved.startsWith(path.resolve(dir))
  );
}

最小权限原则

  • 文件操作工具只允许操作特定目录
  • 数据库工具只暴露只读查询(除非明确需要写入)
  • API Key 通过环境变量注入,不硬编码

11.2 速率限制

AI Agent 可能在循环中反复调用你的工具。如果没有限流机制,可能被 Agent 的一次自动化任务打垮。

code
// 简单的令牌桶限流
class RateLimiter {
  private tokens: number;
  private lastRefill: number;
 
  constructor(private maxTokens: number, private refillPerSecond: number) {
    this.tokens = maxTokens;
    this.lastRefill = Date.now();
  }
 
  tryConsume(): boolean {
    this.refill();
    if (this.tokens < 1) return false;
    this.tokens--;
    return true;
  }
 
  private refill() {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    this.tokens = Math.min(
      this.maxTokens,
      this.tokens + elapsed * this.refillPerSecond
    );
    this.lastRefill = now;
  }
}

在工具 handler 中检查:

code
const limiter = new RateLimiter(10, 1); // 最多 10 个请求,每秒补充 1 个
 
handler: async (args) => {
  if (!limiter.tryConsume()) {
    return {
      content: [{ type: "text", text: "请求过于频繁,请稍后再试" }],
      isError: true,
    };
  }
  // ... 实际业务逻辑
}

11.3 日志和监控

Stdio 模式下不能直接用 console.log 输出日志——因为它会写入 stdout,破坏 MCP 协议的 JSON-RPC 消息流。正确的做法是用 console.error 或专门的日志库:

code
import { createLogger, format, transports } from "winston";
 
const logger = createLogger({
  level: "info",
  format: format.json(),
  transports: [
    new transports.File({ filename: "mcp-server.log" }),
  ],
});
 
export const fileReadTool: Tool = {
  name: "read_file",
  handler: async (args) => {
    logger.info({ event: "tool_call", tool: "read_file", args });
    const result = await doRead(args);
    logger.info({ event: "tool_result", tool: "read_file", success: true });
    return result;
  },
};

11.4 远程部署

如果你需要让 MCP Server 作为远程服务运行(比如团队共享一个数据库查询工具),可以用 SSE 传输 + 反向代理:

code
[AI Agent] ←→ [Nginx / Caddy] ←→ [MCP Server (SSE)]
                                 ←→ [认证中间件 (OAuth / Bearer Token)]

Nginx 配置示例:

code
server {
    listen 443 ssl;
    server_name mcp.yourcompany.com;
 
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_read_timeout 86400s;
    }
}

SSE 连接需要保持长连接,所以 proxy_read_timeout 要设得比较大(24 小时或更长)。Claude Code 的远程 MCP 配置只需把 command 换成 url

code
{
  "mcpServers": {
    "mcp-server-demo": {
      "url": "https://mcp.yourcompany.com/sse"
    }
  }
}

11.5 工具变更通知

如果你需要在运行时动态增减工具(比如从数据库读取插件列表),可以实现 tools/list 变更通知:

code
const server = new Server(
  { name: "mcp-server-demo", version: "1.0.0" },
  {
    capabilities: {
      tools: {
        listChanged: true, // 声明支持工具列表变更通知
      },
    },
  }
);
 
// 当工具列表变化时通知客户端
function notifyToolsChanged() {
  server.sendNotification({
    method: "notifications/tools/list_changed",
  });
}
 
// 添加新工具后发出通知
tools.push(newTool);
notifyToolsChanged();

客户端收到 notifications/tools/list_changed 通知后,会重新调用 tools/list 获取最新的工具列表。

11.6 资源管理

如果你的 MCP Server 管理着数据库连接池、缓存、长连接等资源,记得处理优雅关闭:

code
process.on("SIGINT", async () => {
  console.error("收到 SIGINT,正在关闭服务器...");
  await server.close();
  db.close();
  process.exit(0);
});
 
process.on("SIGTERM", async () => {
  console.error("收到 SIGTERM,正在关闭服务器...");
  await server.close();
  db.close();
  process.exit(0);
});

十二、总结

从零搭建一个 MCP Server 并不复杂——核心就是四个步骤:

  1. 创建 Server 实例:声明名称、版本和能力
  2. 注册 Tools:定义工具名称、描述、输入 schema 和执行逻辑
  3. 连接 Transport:选择 Stdio(本地开发)或 SSE/HTTP(远程部署)
  4. 集成到 AI Agent:配置 Claude Code、Cursor 等工具的 MCP 服务器地址

整个协议层的 JSON-RPC 通信、能力协商、参数序列化都由 @modelcontextprotocol/sdk 处理,你需要关心的只是业务逻辑本身。

MCP 的价值不在于技术复杂性——它很简单。它的价值在于标准化:今天写的这个 MCP Server,明天就可以被 Claude Code 调用,后天可以被 Cursor 调用,大后天可以被你自己写的 AI Agent 调用。工具写一次,到处运行。

如果你需要更完整的参考,可以看 MCP 官方文档@modelcontextprotocol/sdkGitHub 仓库。官方 SDK 里有内置的示例代码,本文的三个工具也只是一个起点——你可以继续添加更多工具:发送邮件、操作 GitHub Issues、查询监控指标、调用内部 API,只要是你能想到的,都能做成 MCP 工具。

分享到
微博Twitter

© 2026 四月 · CC BY-NC-SA 4.0

原文链接:https://aprilzz.com/tutorials/mcp-server-zero-to-hero