MCP

MCP Server 开发实战:从零构建生产级工具服务

手把手教你使用 TypeScript 和 Python 构建高质量的 MCP Server,涵盖 Tools、Resources、Prompts 三大能力的完整实现。

MCP Server 是 Model Context Protocol 生态中的核心组件——它将外部服务的能力暴露给 LLM,让 AI Agent 能够与真实世界交互。这篇文章将带你从环境搭建到生产部署,完整实现一个功能丰富的 MCP Server。

开发环境准备

在开始之前,确保你的开发环境满足以下要求:

  • Node.js 18+(TypeScript 版本)或 Python 3.10+(Python 版本)
  • 一个支持 MCP 的客户端(如 Claude Desktop)

TypeScript 项目初始化

mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
npx tsc --init

tsconfig.json 需要配置 ESM 模块:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true
  }
}

Python 项目初始化

mkdir my-mcp-server && cd my-mcp-server
python -m venv .venv
source .venv/bin/activate
pip install mcp

理解 MCP Server 的核心概念

在编写代码之前,我们需要理解 MCP Server 的三种核心能力各自的设计意图和使用场景。

Tool:让 LLM 执行操作

Tool 是 MCP Server 最重要的能力。它代表一个可以被 LLM 调用的操作,类似于 API 的 endpoint。每个 Tool 需要定义:

  • name:唯一标识符,使用 snake_case
  • description:清晰的功能描述,LLM 根据这个描述决定何时调用
  • inputSchema:JSON Schema 格式的参数规范

Tool 的描述质量直接影响 LLM 的调用准确率。好的描述应该回答三个问题:这个工具做什么?什么时候应该用?参数的含义是什么?

Resource:让 LLM 读取数据

Resource 代表只读的数据源。与 Tool 不同,Resource 不执行任何操作,只是返回数据。Resource 通过 URI 标识,支持静态和动态两种模式。

Prompt:引导 LLM 正确使用

Prompt 是预定义的提示模板,帮助 LLM 理解如何组合使用 Server 提供的工具和资源。

实战:构建数据库查询 Server

让我们构建一个实用的 MCP Server——SQLite 数据库查询工具。这个 Server 将允许 AI Agent 查询数据库、查看表结构和执行分析。

TypeScript 实现

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import Database from 'better-sqlite3';

const db = new Database(process.argv[2] || 'data.db');
const server = new McpServer({
  name: 'sqlite-query',
  version: '1.0.0',
});

// 查询工具
server.tool(
  'query',
  '执行 SQL 查询并返回结果。仅支持 SELECT 语句。',
  {
    sql: z.string().describe('要执行的 SQL SELECT 语句'),
  },
  async ({ sql }) => {
    // 安全校验:只允许 SELECT
    const trimmed = sql.trim().toUpperCase();
    if (!trimmed.startsWith('SELECT')) {
      return {
        content: [{ type: 'text', text: '错误:只允许执行 SELECT 查询' }],
        isError: true,
      };
    }

    try {
      const rows = db.prepare(sql).all();
      return {
        content: [{
          type: 'text',
          text: JSON.stringify(rows, null, 2),
        }],
      };
    } catch (error) {
      return {
        content: [{ type: 'text', text: `查询错误:${error.message}` }],
        isError: true,
      };
    }
  }
);

// 表结构查看
server.tool(
  'list_tables',
  '列出数据库中的所有表',
  {},
  async () => {
    const tables = db.prepare(
      "SELECT name FROM sqlite_master WHERE type='table'"
    ).all();
    return {
      content: [{
        type: 'text',
        text: tables.map((t: any) => t.name).join('\n'),
      }],
    };
  }
);

// 表结构详情
server.tool(
  'describe_table',
  '查看指定表的列定义和数据类型',
  { tableName: z.string().describe('表名') },
  async ({ tableName }) => {
    // 防止 SQL 注入
    if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
      return {
        content: [{ type: 'text', text: '无效的表名' }],
        isError: true,
      };
    }

    const columns = db.prepare(`PRAGMA table_info(${tableName})`).all();
    return {
      content: [{
        type: 'text',
        text: JSON.stringify(columns, null, 2),
      }],
    };
  }
);

// 资源:数据库元信息
server.resource(
  'db-info',
  'sqlite://info',
  async (uri) => {
    const tables = db.prepare(
      "SELECT name FROM sqlite_master WHERE type='table'"
    ).all();
    const info = tables.map((t: any) => {
      const count = db.prepare(`SELECT COUNT(*) as cnt FROM ${t.name}`).get();
      return `${t.name}: ${count.cnt} 行`;
    });

    return {
      contents: [{
        uri: uri.href,
        text: `数据库表统计:\n${info.join('\n')}`,
      }],
    };
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);

Python 实现

import sqlite3
import sys
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent

db_path = sys.argv[1] if len(sys.argv) > 1 else "data.db"
db = sqlite3.connect(db_path)
server = Server("sqlite-query")

@server.list_tools()
async def list_tools():
    return [
        Tool(
            name="query",
            description="执行 SQL SELECT 查询",
            inputSchema={
                "type": "object",
                "properties": {
                    "sql": {
                        "type": "string",
                        "description": "SQL SELECT 语句"
                    }
                },
                "required": ["sql"]
            }
        ),
        Tool(
            name="list_tables",
            description="列出所有数据库表",
            inputSchema={"type": "object", "properties": {}}
        ),
    ]

@server.call_tool()
async def call_tool(name, arguments):
    if name == "query":
        sql = arguments["sql"]
        if not sql.strip().upper().startswith("SELECT"):
            return [TextContent(type="text", text="仅支持 SELECT 查询")]
        try:
            cursor = db.execute(sql)
            rows = cursor.fetchall()
            columns = [desc[0] for desc in cursor.description]
            result = [dict(zip(columns, row)) for row in rows]
            return [TextContent(type="text", text=str(result))]
        except Exception as e:
            return [TextContent(type="text", text=f"错误:{e}")]

    elif name == "list_tables":
        cursor = db.execute(
            "SELECT name FROM sqlite_master WHERE type='table'"
        )
        tables = [row[0] for row in cursor.fetchall()]
        return [TextContent(type="text", text="\n".join(tables))]

async def main():
    async with stdio_server() as (read_stream, write_stream):
        await server.run(read_stream, write_stream)

if __name__ == "__main__":
    import asyncio
    asyncio.run(main())

Tool 设计的最佳实践

经过大量实践,我总结了以下 Tool 设计原则:

描述要精确

模糊的描述会导致 LLM 错误调用。比较以下两个描述:

# 差
description: "查询数据"

# 好
description: "执行 SQL SELECT 查询语句,返回查询结果。仅支持读取操作,不支持 INSERT/UPDATE/DELETE。"

参数校验要严格

使用 Zod(TypeScript)或 Pydantic(Python)进行严格的参数校验。这不仅能防止错误输入,还能帮助 LLM 理解参数的约束条件。

server.tool(
  'search',
  '在指定目录中搜索文件',
  {
    directory: z.string().describe('搜索目录的绝对路径'),
    pattern: z.string().describe('文件名匹配模式,支持通配符 * 和 ?'),
    maxResults: z.number().int().min(1).max(100).default(10)
      .describe('最大返回数量,默认 10'),
  },
  handler
);

错误信息要有用

当工具执行失败时,返回清晰的错误信息,帮助 LLM 理解问题所在并做出调整。

catch (error) {
  return {
    content: [{
      type: 'text',
      text: `文件不存在:${filePath}\n请检查路径是否正确,或使用 list_files 工具查看可用文件。`,
    }],
    isError: true,
  };
}

注册到 Claude Desktop

开发完成后,需要在 Claude Desktop 的配置文件中注册你的 MCP Server。

编辑 ~/Library/Application Support/Claude/claude_desktop_config.json(macOS)或 %APPDATA%\Claude\claude_desktop_config.json(Windows):

{
  "mcpServers": {
    "sqlite": {
      "command": "node",
      "args": ["/path/to/dist/index.js", "/path/to/database.db"]
    }
  }
}

重启 Claude Desktop 后,你就可以在对话中使用这些工具了。

常见问题(FAQ)

MCP Server 如何处理并发请求?

stdio 传输模式下,Server 按顺序处理请求。如果需要并发支持,可以使用 HTTP+SSE 传输模式,配合 Node.js 的异步处理能力实现并发。

工具调用的超时时间如何设置?

MCP 协议本身不定义超时时间,由 Client 端控制。建议在 Server 端也实现自己的超时机制,避免长时间运行的查询阻塞整个服务。

如何测试 MCP Server?

可以使用 MCP Inspector 工具进行交互式测试。运行 npx @modelcontextprotocol/inspector node dist/index.js 即可启动一个 Web 界面来测试你的 Server。

总结

构建一个高质量的 MCP Server 需要关注三个核心要素:清晰的 Tool 描述、严格的参数校验和有用的错误信息。遵循这些原则,你的 Server 就能被 LLM 准确、高效地使用。

下一步,我们将探讨如何构建 MCP Client,让你的 AI Agent 能够同时管理多个 MCP Server 的连接。