用 Node.js + TypeScript 从零开发 MCP Server 并部署上线,我踩了 7 个坑

2026-05-03 21:38 用 Node.js + TypeScript 从零开发 MCP Server 并部署上线,我踩了 7 个坑已关闭评论

用 Node.js + TypeScript 从零开发 MCP Server 并部署上线,我踩了 7 个坑

团队需要一个内部工具来统一管理多个 AI 客户端的工具调用和资源访问,我负责开发一个 MCP Server。

问题是什么

去年我们团队同时接入了 Claude Desktop、Cursor 和自研的 AI Agent 系统。每个客户端都有自己的工具调用规范,同样的数据库查询逻辑要在三套体系里各写一遍。维护成本越来越高,工具函数散落在不同代码库里,没法统一做权限控制和审计。

MCP(Model Context Protocol)是 Anthropic 开源的通用协议,让 AI 应用通过标准化的方式调用外部工具和获取资源。简单说,它就是 AI 世界的「USB-C 接口」—— 服务端暴露工具和资源,任何支持 MCP 的客户端都能直接调用。

问题是当时 MCP 还比较新(2024 年底才发布),官方文档偏概念化,生产级别的示例不多。我需要自己趟一遍从开发到部署的全流程。

解决思路

当时有几个方案可选:

  • **Python SDK(官方)**:成熟度最高,但我们的技术栈主要在 Node.js 生态
  • **TypeScript SDK(官方)**:与 Node.js 技术栈匹配,类型安全,优先考虑
  • **自己写协议实现**:从零实现 JSON-RPC 2.0 + SSE/stdio 传输层,不现实
  • 选了官方 TypeScript SDK(@modelcontextprotocol/sdk),原因很简单:团队全员 TypeScript,后续维护成本最低。

    传输层选型上纠结过:

    传输层 适用场景 缺点
    stdio 本地 CLI 工具 无法远程访问
    SSE 远程服务 客户端单向推送
    Streamable HTTP 远程服务(双向) 最新,生态支持少

    最终选了 SSE + HTTP 的组合方案:SSE 做服务端推送,HTTP POST 做客户端请求。

    操作步骤

    步骤 1:初始化项目和安装依赖

    mkdir mcp-server && cd mcp-server
    npm init -y
    npm install @modelcontextprotocol/sdk zod express cors
    npm install -D typescript @types/node @types/express tsx
    

    zod 是 MCP SDK 默认推荐的参数校验库,tsx 用于开发时的热重载。

    tsconfig.json 配置:

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

    步骤 2:实现核心 Server 类

    MCP Server 的核心是注册 Tool 和 Resource,然后启动传输层。先搭骨架:

    // src/index.ts
    import { Server } from "@modelcontextprotocol/sdk/server/index.js";
    import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
    import {
      CallToolRequestSchema,
      ListToolsRequestSchema,
      ListResourcesRequestSchema,
      ReadResourceRequestSchema,
    } from "@modelcontextprotocol/sdk/types.js";
    
    const server = new Server(
      { name: "my-mcp-server", version: "0.1.0" },
      { capabilities: { tools: {}, resources: {} } }
    );
    
    // 注册工具列表
    server.setRequestHandler(ListToolsRequestSchema, async () => ({
      tools: [
        {
          name: "query_database",
          description: "执行数据库查询",
          inputSchema: {
            type: "object",
            properties: {
              sql: { type: "string", description: "SQL 查询语句" },
              params: { type: "array", items: { type: "string" } },
            },
            required: ["sql"],
          },
        },
        {
          name: "get_weather",
          description: "获取城市天气",
          inputSchema: {
            type: "object",
            properties: {
              city: { type: "string" },
            },
            required: ["city"],
          },
        },
      ],
    }));
    
    // 处理工具调用
    server.setRequestHandler(CallToolRequestSchema, async (request) => {
      const { name, arguments: args } = request.params;
    
      switch (name) {
        case "query_database":
          return await handleQueryDatabase(args);
        case "get_weather":
          return await handleGetWeather(args);
        default:
          throw new Error(`Unknown tool: ${name}`);
      }
    });
    
    // 启动
    async function main() {
      const transport = new StdioServerTransport();
      await server.connect(transport);
      console.error("MCP Server running on stdio");
    }
    
    main().catch(console.error);
    

    注意: 这里用 console.error 而不是 console.log 输出日志。MCP 的 stdio 传输层将 stdout 用于协议通信,混用会破坏 JSON-RPC 消息。

    步骤 3:添加参数校验(踩坑记录)

    第一个坑:不校验参数直接抛运行时错误,客户端收到的是无意义的错误码

    用 zod 做参数校验:

    // src/tools/queryDatabase.ts
    import { z } from "zod";
    import { zodToJsonSchema } from "zod-to-json-schema"; // 需要单独安装
    
    const QueryDatabaseSchema = z.object({
      sql: z.string().min(1, "SQL 不能为空"),
      params: z.array(z.string()).optional().default([]),
      timeout: z.number().min(100).max(30000).optional().default(5000),
    });
    
    type QueryDatabaseParams = z.infer<typeof QueryDatabaseSchema>;
    
    export async function handleQueryDatabase(rawArgs: unknown) {
      const parsed = QueryDatabaseSchema.safeParse(rawArgs);
      if (!parsed.success) {
        return {
          content: [{ type: "text", text: `参数错误: ${parsed.error.message}` }],
          isError: true,
        };
      }
    
      const { sql, params, timeout } = parsed.data;
      // 实际的数据库查询逻辑...
      return {
        content: [{ type: "text", text: JSON.stringify(result) }],
      };
    }
    

    这样客户端收到 isError: true 后能友好地展示错误信息,而不是吐一个 InternalError

    步骤 4:实现 SSE 传输层(踩坑记录)

    stdio 只能跑在本地,要部署成远程服务必须换传输层。MCP SDK 提供了 SSE 传输层,但文档里只给了 stdio 的例子。

    第二个坑:官方 SDK 的 SSEServerTransport 需要配合 Express 手动管理连接

    // src/sse-server.ts
    import express from "express";
    import { Server } from "@modelcontextprotocol/sdk/server/index.js";
    import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
    
    const app = express();
    let transport: SSEServerTransport | null = null;
    
    // 客户端先连这个端点建立 SSE 连接
    app.get("/sse", async (req, res) => {
      transport = new SSEServerTransport("/messages", res);
      await server.connect(transport);
    });
    
    // 客户端通过这个端点发送请求
    app.post("/messages", async (req, res) => {
      if (!transport) {
        res.status(400).send("No active SSE connection");
        return;
      }
      await transport.handlePostMessage(req, res);
    });
    
    app.listen(3001, () => {
      console.error("MCP SSE server running on port 3001");
    });
    

    第三个坑:多客户端连接时 transport 会被覆盖。上面那个写法只能支持一个客户端。生产环境需要用 Map 管理多个 transport:

    const transports = new Map<string, SSEServerTransport>();
    
    app.get("/sse", async (req, res) => {
      const sessionId = crypto.randomUUID();
      const transport = new SSEServerTransport("/messages", res);
      transports.set(sessionId, transport);
    
      res.on("close", () => {
        transports.delete(sessionId);
      });
    
      await server.connect(transport);
    });
    
    app.post("/messages/:sessionId", async (req, res) => {
      const transport = transports.get(req.params.sessionId);
      if (!transport) {
        res.status(404).send("Session not found");
        return;
      }
      await transport.handlePostMessage(req, res);
    });
    

    步骤 5:添加 Resource 支持(踩坑记录)

    Tool 是「主动调用」,Resource 是「被动读取」——AI 客户端会在需要时读取资源来获取上下文。比如给 AI 提供数据库 schema 信息:

    server.setRequestHandler(ListResourcesRequestSchema, async () => ({
      resources: [
        {
          uri: "schema://users",
          name: "Users Table Schema",
          mimeType: "application/json",
          description: "users 表的字段定义",
        },
        {
          uri: "schema://orders",
          name: "Orders Table Schema",
          mimeType: "application/json",
          description: "orders 表的字段定义",
        },
      ],
    }));
    
    server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
      const uri = request.params.uri;
      // 根据 uri 返回对应的 schema 信息
      const schema = await getTableSchema(uri.replace("schema://", ""));
      return {
        contents: [{ uri, mimeType: "application/json", text: JSON.stringify(schema) }],
      };
    });
    

    第四个坑:Resource URI 要有规范的前缀。我之前用了 db://schema:// 混在一起,客户端解析时出过歧义。后来统一成 mcp://server-name/resource-type/resource-id 的格式。

    步骤 6:部署到生产环境(踩坑记录)

    第五个坑:SSE 需要保持长连接,普通 Serverless 部署(AWS Lambda / Vercel Functions)不适用

    解决方案是用 Docker 部署到 ECS/Fargate 这类支持长连接的容器服务:

    FROM node:20-alpine AS builder
    WORKDIR /app
    COPY package*.json tsconfig.json ./
    RUN npm ci
    COPY src/ ./src/
    RUN npm run build
    
    FROM node:20-alpine
    WORKDIR /app
    COPY --from=builder /app/dist ./dist
    COPY --from=builder /app/node_modules ./node_modules
    EXPOSE 3001
    CMD ["node", "dist/sse-server.js"]
    
    # docker-compose.yml(本地测试用)
    version: "3.8"
    services:
      mcp-server:
        build: .
        ports:
          - "3001:3001"
        environment:
          - DATABASE_URL=postgres://user:pass@host:5432/db
          - LOG_LEVEL=info
        healthcheck:
          test: ["CMD", "wget", "--spider", "http://localhost:3001/sse"]
          interval: 30s
          timeout: 10s
          retries: 3
    

    第六个坑:容器健康检查对 SSE 端点不适用GET /sse 会一直挂起等待连接,健康检查会超时。应该单独加一个健康检查端点:

    app.get("/health", (req, res) => {
      res.json({ status: "ok", connections: transports.size });
    });
    

    步骤 7:客户端配置与测试

    以 Claude Desktop 为例,通过 claude_desktop_config.json 配置远程 MCP Server:

    {
      "mcpServers": {
        "my-server": {
          "type": "sse",
          "url": "https://mcp-server.example.com/sse"
        }
      }
    }
    

    开发阶段也可以通过 stdio 直接测试:

    # 直接启动,Claude Desktop 配置指向本地路径
    node dist/index.js
    
    # 或者用 tsx 开发模式
    npx tsx src/index.ts
    

    第七个坑:测试用 curl 调试 SSE 端点。SSE 是文本流,直接 curl 就能看到推送的消息:

    curl -N https://mcp-server.example.com/sse
    

    -N 参数禁用缓冲,这样才能实时看到 SSE 事件流。

    结果与总结

    整个 MCP Server 从开发到部署花了 3 天,代码量不到 500 行。上线后统一了 Claude Desktop、Cursor 和内部 Agent 系统的工具调用接口,新增一个工具函数只需要在 MCP Server 里加一次。

    几个关键经验:

  • **传输层选 SSE + HTTP POST** — 适合远程部署,单客户端场景 stdio 就够,多客户端必须用 Map 管理 session
  • **参数校验一定要做** — zod 的 `safeParse` + 返回 `isError: true`,比抛异常友好得多
  • **Resource URI 要有统一规范** — 建议 `mcp://server-name/resource-type/resource-id`
  • **Serverless 不适用** — SSE 长连接需要容器化部署
  • **健康检查和 SSE 端点分开** — 不要用 SSE 端点做健康检查
  • **日志用 stderr** — 容易忽略但破坏性很大的细节
  • 延伸思考

    鉴权问题。目前生产环境只加了简单的 API Key 验证(通过 SSE URL query parameter)。下一步打算接入 OAuth 2.0,MCP SDK 已经支持了 Authorization 相关的协议扩展。看最新版本的 SDK,Anthropic 正在推 Authorization 支持的标准化。

    工具编排。多个 MCP Server 如何协同?可以用 MCP Gateway 做统一入口,类似 API Gateway 的概念。Claude Desktop 本身支持配置多个 MCP Server,但还做不了 Server 间的工具组合编排。

    MCP 协议的未来。目前 MCP 还在活跃迭代中(v0.x),1.0 还没出。关注 streaming responses、batch operations 等提案,这些会影响工具调用的效率。

    替代协议。Google 的 A2A(Agent-to-Agent)协议也在推进中,和 MCP 是互补关系——MCP 解决 AI 与工具的通信,A2A 解决 Agent 与 Agent 的通信。如果你在构建多 Agent 系统,这两个协议都值得关注。

    你可能感兴趣的文章

    来源:每日教程每日一例,深入学习实用技术教程,关注公众号TeachCourse
    转载请注明出处: https://teachcourse.cn/4053.html ,谢谢支持!

    资源分享

    分类:Android 标签:
    一键pdf转文本工具 一键pdf转文本工具
    014-wordpress如何实现配置CORS策略来限制哪些域可以访问你的API 014-wordpress如何实现配置COR
    文章摘要生成器 文章摘要生成器
    微信开放平台开发之闪退问题解决办法 微信开放平台开发之闪退问题解决

    评论已关闭!