上个月接了个需求:让团队用自然语言查开发数据库。一开始想的自然是写个 API 查完返回 JSON,但很快发现问题——数据拿到了还得人工粘贴给 AI 分析,流程断的。正好在研究 MCP,索性直接写了个 Server,让 LLM 自己调。

两个星期踩了不少坑,记录下从零到一的完整过程。

最小的 MCP Server

MCP 的 Server 端说白了就是一个 JSON-RPC 端点,stdio 或 HTTP(S) 都行。用 TypeScript + @modelcontextprotocol/sdk 起步:

import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

const server = new Server({
  name: 'db-query-server',
  version: '0.1.0'
}, {
  capabilities: { tools: {} }
});

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

这套模版是我所有 MCP Server 的起点。注意 capabilities 里要声明 tools,不然 Client 不会发 ListTools 请求。

Tool 定义:带 JSON Schema 的才是好 Tool

关键来了——Tool 的入参用 JSON Schema 描述,LLM 才能正确生成参数。我一开始图省事,参数全用 string,结果 Claude 每次传参都瞎编:

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [{
    name: 'query_database',
    description: '对 SQLite 数据库执行 SELECT 查询,返回行数组',
    inputSchema: {
      type: 'object',
      properties: {
        sql: {
          type: 'string',
          description: '完整的 SELECT SQL,必须用 ? 占位符传参'
        },
        params: {
          type: 'array',
          items: { type: ['string', 'number', 'null'] },
          description: '与 ? 一一对应的参数值'
        },
        limit: {
          type: 'number',
          description: '最大返回行数,默认 50',
          default: 50
        }
      },
      required: ['sql']
    }
  }]
}))

schema 写得越细,LLM 参数命中率越高。尤其 description 字段——别写 “SQL 语句”,要写 “完整的 SELECT SQL,必须用 ? 占位符传参”,模型才会乖乖用预处理语句。

执行查询:这里踩了个坑

查询实现看起来简单,但第一次写的时候我忘了关闭数据库连接:

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { sql, params = [], limit = 50 } = request.params.arguments;

  const db = new Database('dev.db');
  // ⚠️ 第一版没有 try/finally,连接泄漏!
  try {
    const rows = db.prepare(sql).all(...params).slice(0, limit);
    return {
      content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }]
    };
  } finally {
    db.close();
  }
});

连接泄漏只是第一个坑。第二个坑是 LLM 生成 SQL 时可能传 delete 或 drop——必须做安全校验:

const dangerous = /\b(drop|delete|truncate|alter|create|insert|update|replace)\b/i;
if (dangerous.test(sql)) {
  return {
    content: [{ type: 'text', text: '危险操作已拦截:只允许 SELECT 查询' }],
    isError: true
  };
}

MCP Inspector 调试

写完之后怎么测?官方的 MCP Inspector 比 Postman 好用十倍:

npx @modelcontextprotocol/inspector node dist/server.js

打开 http://localhost:5173,能看到自动展示所有注册的 Tool、填参数调用的界面、以及完整的 JSON-RPC 报文。当初调 JSON Schema 的时候,就是靠 Inspector 发现某个 parameter 的 type 写成了 stirng,模型传参一直 400。

接入 Claude

Claude Desktop 的 mcpServers 配置:

{
  "mcpServers": {
    "db-query": {
      "command": "node",
      "args": ["D:/projects/db-query-server/dist/server.js"]
    }
  }
}

配置完重启 Claude,对话框里会出现一个锤子图标,点开就能看到 query_database。这时候你可以直接说 “查一下上个月注册的用户数”,Claude 会自动调你的 Server。(第一次成功的时候还挺有成就感的。)

查了下已经公开的 MCP Server 仓库,很多只暴露了工具定义就发布了,连参数描述都是空的。模型拿这种工具就像人用一台没有标签的机器——全靠猜。写 schema 的时候把自己当成用户和模型之间的翻译,描述写到位了,效果天差地别。另外安全拦截无论如何都不能省——LLM 有时候真的会生成 drop table,别问我怎么知道的。

MCP 架构图:LLM ↔ MCP Server ↔ 数据库

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐