Node.js构建可用的 MCP 服务器:从入门到实战

217 阅读10分钟

MCP(Model Context Protocol)是什么?一句话:帮你把任意 LLM(大型语言模型)和外部系统的对接,统一、简单、高效。

在本篇文章里,你将学会:

  • MCP 的概念和使用场景
  • MCP 的工作原理与数据流
  • 用 Node.js 搭建完整 MCP 服务器
  • cURL 与可视化调试全流程实测
  • 生产环境注意事项与优化建议

国内开发者们快看!Cursor中文文档已经全面上线!现在,你可以通过母语更轻松地掌握这款强大的AI编码工具的全部功能,关于Cursor的开发技巧和博客都在这里。

更多精彩Cursor开发技巧博客地址cursor.npmlib.com/blogs/curso…

更多Cursor使用技巧也可关注公众号 AI近距离

MCP 究竟是什么?为什么值得用

MCP,全称 Model Context Protocol,是一套基于 JSON-RPC 2.0 的统一协议,用于让 LLM 和外部系统交互。它主要暴露三类能力:

  1. 资源(Resources):只读数据访问,相当于 GET
  2. 工具(Tools):执行业务逻辑,有副作用操作
  3. 提示(Prompts):可复用的消息模板

为什么需要 MCP?

  • 旧方式:函数调用和插件

在 MCP 之前,每个 AI 供应商都有自己的“函数调用”或“插件”系统。 例如:

OpenAI 2023 年的函数调用 API 要求你为每个用例定义自定义的 JSON 模式。

Claude 有一个不同的机制,与 OpenAI 的格式不兼容。

如果你今天为 GPT-4 构建了一个连接器,明天就得为 Claude 重建一个。这导致了连接器数量的 N × M 爆炸式增长。

  • MCP 登场:一座通用桥梁 MCP 提供了一个单一的协议(基于 HTTP 或 SSE 的 JSON-RPC 2.0),任何 LLM 客户端都可以使用它来调用你服务器的工具和资源。当一个 LLM 客户端连接时,它首先发送一个 initialize 请求。作为响应,你的服务器会明确通告它支持哪些工具、资源和提示。

完成这个握手后,AI 就可以:

  • mcp/readResource → 获取数据,例如从数据库读取个人资料。
  • mcp/callTool → 运行代码,例如 BMI 计算器。
  • mcp/getPrompt → 检索可重用的消息模板。

因为它是标准化的,所以你无需为每个 AI 模型编写单独的连接器。

MCP 的主要优势

  • 跨多个 AI 平台标准化。

  • 基于 JSON-RPC 2.0: 一个众所周知的、稳定的协议。

  • 支持流式 HTTP: 通过 HTTP + SSE,服务器可以持续下发事件给客户端。

  • 能力通告: 服务器明确告知客户端可用的工具、资源和提示。

  • 可扩展: 你可以动态添加或删除工具/资源,并自动通知任何监听的客户端。

⚡ 小提示:MCP 设计初衷就是“可复用、可发现、可流式”,方便快速构建复杂 LLM 应用

核心 MCP 概念

在编写代码之前,让我们用简单的语言回顾一下 MCP 的主要组成部分。

  • 服务器 (Server)

一个 McpServer 是核心对象,负责管理:

  • 能力 (Capabilities): 声明是否支持工具、资源和提示。

  • 注册表 (Registry): 保存已注册的工具、资源和提示。

  • 协议合规性: 处理传入的 JSON-RPC 消息 (initialize, callTool, readResource 等)。

在 Node.js (TypeScript/JavaScript) 中,你可以这样创建它:

const server = new McpServer({
  name: 'my-mcp-server',
  version: '1.0.0',
  capabilities: {
    tools:     { listChanged: true },
    resources: { listChanged: true },
    prompts:   { listChanged: true }
  }
});
  • 工具 (Tools)

工具是 AI 可以调用来执行某些操作(计算或产生副作用)的函数。 注册工具时,你需要提供:

  • 名称 (字符串), 例如:"calculate-bmi".

  • 其参数的 Zod 模式(以便 MCP 可以自动验证)。

  • 一个异步处理函数,接收解析后的参数并返回结果或错误。 示例:

server.tool(
  'calculate-bmi',
  { weightKg: z.number(), heightM: z.number() },
  async ({ weightKg, heightM }) => {
    const bmi = weightKg / (heightM * heightM);
    return {
      content: [{ type: 'text', text: String(bmi) }]
    };
  }
);

注册后,AI 客户端可以进行 JSON-RPC 调用:

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "mcp/callTool",
  "params": {
    "name": "calculate-bmi",
    "arguments": { "weightKg": 70, "heightM": 1.75 }
  }
}
  • 资源 (Resources) 资源向 AI 暴露数据。它们类似于 "GET" 端点:
server.resource(
  'user-profile',
  new ResourceTemplate('users://{userId}/profile', { list: undefined }),
  async (uri, { userId }) => {
    // 例如,从你的数据库获取
    return {
      contents: [{
        uri: uri.href,
        text: `Profile data for user ${userId}`
      }]
    };
  }
);

客户端随后可以发送:

{
  "jsonrpc": "2.0",
  "id": 4,
  "method": "mcp/readResource",
  "params": { "uri": "users://123/profile" }
}
  • 提示 (Prompts) 提示是可重用的消息模板。它们帮助为 LLM 格式化请求:
server.prompt(
  'review-code',
  { code: z.string() },
  ({ code }) => ({
    messages: [{
      role: 'user',
      content: {
        type: 'text',
        text: `Please review this code:\n\n${code}`
      }
    }]
  })
);

然后,AI 可以执行:

{
 "jsonrpc": "2.0",
 "id": 5,
 "method": "mcp/getPrompt",
 "params": { "name": "review-code", "arguments": { "code": "const a = 1;" } }
}

Node.js 搭建完整 MCP 服务器

现在我们将构建一个完整的 Express 服务器,通过可流式 HTTP 暴露我们的 MCP 端点。

先决条件:

  • Node.js v18+
  • 依赖安装:
npm install express @modelcontextprotocol/sdk zod

我们将使用可流式 HTTP 传输 (Streamable HTTP transport) 来实现实时通知 (SSE)。


完整 server.js:
```js
/**
 * server.js
 *
 * Express MCP Server (Streamable HTTP, Stateful)
 *
 *  - 声明 MCP 能力(tools/resources/prompts)
 *  - 资源:config://app(静态)、users://{userId}/profile(动态)
 *  - 工具:calculate-bmi
 *  - 提示:review-code
 *  - 会话内持久化 McpServer 实例
 */

const express = require('express');
const { randomUUID } = require('crypto');
const { McpServer, ResourceTemplate } = require('@modelcontextprotocol/sdk/server/mcp.js');
const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
const { isInitializeRequest } = require('@modelcontextprotocol/sdk/types.js');
const { z } = require('zod');

const app = express();
app.use(express.json());

// 简单的内存会话表
const sessions = {};

function createMcpServer() {
  const server = new McpServer({
    name: 'example-server',
    version: '1.0.0',
    capabilities: {
      tools:     { listChanged: true },
      resources: { listChanged: true },
      prompts:   { listChanged: true }
    }
  });

  // 静态资源 config://app
  server.resource(
    'config',
    'config://app',
    async (uri) => {
      return {
        contents: [
          { uri: uri.href, text: 'App configuration here' }
        ]
      };
    }
  );

  // 动态资源 users://{userId}/profile
  server.resource(
    'user-profile',
    new ResourceTemplate('users://{userId}/profile', { list: undefined }),
    async (uri, { userId }) => {
      return {
        contents: [
          {
            uri: uri.href,
            text: `Profile data for user ${userId}`
          }
        ]
      };
    }
  );

  // 工具 calculate-bmi
  server.tool(
    'calculate-bmi',
    { weightKg: z.number(), heightM: z.number() },
    async ({ weightKg, heightM }) => {
      const bmi = weightKg / (heightM * heightM);
      return {
        content: [
          { type: 'text', text: String(bmi) }
        ]
      };
    }
  );

  // 提示 review-code
  server.prompt(
    'review-code',
    { code: z.string() },
    ({ code }) => {
      return {
        messages: [
          {
            role: 'user',
            content: {
              type: 'text',
              text: `Please review this code:\n\n${code}`
            }
          }
        ]
      };
    }
  );

  return server;
}

// POST /mcp:初始化或复用会话
app.post('/mcp', async (req, res) => {
  const sessionIdHeader = req.headers['mcp-session-id'];
  let sessionEntry = null;

  // 情况1:复用已存在的会话
  if (sessionIdHeader && sessions[sessionIdHeader]) {
    sessionEntry = sessions[sessionIdHeader];

  // 情况2:无会话但这是初始化请求 → 建立新会话
  } else if (!sessionIdHeader && isInitializeRequest(req.body)) {
    const newSessionId = randomUUID();

    const transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => newSessionId,
      onsessioninitialized: (sid) => {
        sessions[sid] = { server, transport };
      }
    });

    transport.onclose = () => {
      if (transport.sessionId && sessions[transport.sessionId]) {
        delete sessions[transport.sessionId];
      }
    };

    const server = createMcpServer();
    await server.connect(transport);

    sessions[newSessionId] = { server, transport };
    sessionEntry = sessions[newSessionId];

  } else {
    res.status(400).json({
      jsonrpc: '2.0',
      error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
      id: null
    });
    return;
  }

  // 将请求转交给本会话的 transport
  await sessionEntry.transport.handleRequest(req, res, req.body);
});

// GET/DELETE /mcp:SSE 下行与关闭会话
async function handleSessionRequest(req, res) {
  const sessionIdHeader = req.headers['mcp-session-id'];
  if (!sessionIdHeader || !sessions[sessionIdHeader]) {
    res.status(400).send('Invalid or missing session ID');
    return;
  }
  const { transport } = sessions[sessionIdHeader];
  await transport.handleRequest(req, res);
}

app.get('/mcp', handleSessionRequest);
app.delete('/mcp', handleSessionRequest);

// 启动
const PORT = 7171;
app.listen(PORT, () => {
  console.log(`MCP Server listening on port ${PORT}`);
});

关键点说明

  • Express:我们使用 app.use(express.json()),以便 Express 解析所有 JSON 请求体。

  • 会话存储:我们维护一个内存中的对象 sessions,以 sessionId 为键,存储 { server, transport }。这确保了每个客户端保持相同的 McpServer 实例,因此工具在多次调用之间保持注册状态。

  • 初始化流程createMcpServer():

    • 我们传递 capabilities: { tools, resources, prompts } 并将 listChanged 设为 true,以便在初始化握手期间,服务器明确通告可用的工具/资源/提示。

    • 注册两个资源 (config://appusers://{userId}/profile)、一个工具 (calculate-bmi) 和一个提示 (review-code)。

  • POST /mcp 处理程序:

    • 如果 mcp-session-id 存在且匹配 sessions 中的条目,我们将请求转发给该会话的 transport.handleRequest(...)

    • 如果没有 sessionId 且请求体是 MCP 初始化请求 (isInitializeRequest(req.body)),我们创建一个新会话:

      • 生成 newSessionId = randomUUID()。

      • 实例化 StreamableHTTPServerTransport,提供 sessionIdGenerator 和 onsessioninitialized 回调。

      • 调用 createMcpServer() 来注册工具/资源/提示。

      • 在发送任何响应之前调用 await server.connect(transport)

      • { server, transport } 存储在 sessions[newSessionId] 中。

    • 最后,将 JSON-RPC 请求体转发给 transport.handleRequest(req, res, req.body),以便 MCP 服务器处理它。

  • GET/DELETE /mcp 处理程序:

    • 客户端使用这些端点接收 SSE 通知 (GET) 或关闭会话 (DELETE)。我们再次在请求头中查找 sessionId,找到对应的 transport 并调用 transport.handleRequest(req, res)

测试 MCP 服务器

有两种主要方法测试你的 MCP 服务器:

  1. 使用 cURL(手动发送 JSON-RPC 调用)。
  2. 使用 MCP Inspector(一个通过 npx 运行的交互式 CLI/UI 工具)。

启动服务器

在你的项目文件夹中打开终端并运行:

node server.js

你应该看到:

MCP Server listening on port 7171

这表示 Express 已启动并运行,准备在 http://localhost:7171/mcp 接受 MCP JSON-RPC 调用。

使用 cURL 测试

1. 初始化会话:

发送一个 initialize 请求。如果你想让你工具看到它,请确保包含 Authorization 头:

curl -i -X POST http://localhost:7171/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "Authorization: Bearer my-secret-token" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {
      "protocolVersion": "2024-11-05",
      "capabilities": { "interactive": true },
      "clientInfo": { "name": "example-client", "version": "1.0.0" }
    }
  }'

预期响应(示例)

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
mcp-session-id: 550e8400-e29b-41d4-a716-446655440000
Date: Sat, 05 Jun 2025 18:00:00 GMT

event: message
data: {
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "resources": { "listChanged": true },
      "tools":     { "listChanged": true },
      "prompts":   { "listChanged": true }
    },
    "serverInfo": {
      "name": "example-server",
      "version": "1.0.0",
      "capabilities": {
        "resources": { "listChanged": true },
        "tools":     { "listChanged": true },
        "prompts":   { "listChanged": true }
      }
    }
  }
}

注意头信息 mcp-session-id: 550e8400-e29b-41d4-a716-446655440000。你必须精确复制这个值(区分大小写)用于后续调用。

在 "result.capabilities" 中,你看到服务器通告支持工具、资源和提示。

2. 调用 calculate-bmi 工具:

使用上面获得的会话 ID:

curl -X POST http://localhost:7171/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "mcp-session-id: 550e8400-e29b-41d4-a716-446655440000" \
  -d '{
    "jsonrpc": "2.0",
    "id": 2,
    "method": "mcp/callTool",
    "params": {
      "name": "calculate-bmi",
      "arguments": { "weightKg": 70, "heightM": 1.75 }
    }
  }'

预期响应:

event: message
data: {
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "22.857142857142858"
      }
    ]
  }
}

常见问题故障排除

以下是你可能遇到的最常见问题及其解决方法。

1. 调用工具时出现 "Method not found"

现象: 当你调用 mcp/callTool 时,得到:

{ "jsonrpc":"2.0", "id":2, "error": { "code": -32601, "message": "Method not found" } }

可能原因和解决方法:

  • 未声明能力 (Capabilities): 如果你在 new McpServer(...) 中省略了 capabilities,客户端将不知道任何工具存在。务必包含:
const server = new McpServer({
  name: 'example-server',
  version: '1.0.0',
  capabilities: {
    tools:     { listChanged: true },
    resources: { listChanged: true },
    prompts:   { listChanged: true }
  }
});
  • server.connect(...) 之后注册工具: 如果你在注册工具之前调用 server.connect(transport),初始化握手永远不会通告这些工具。确保顺序是:
createMcpServer();    // 注册所有内容
await server.connect(transport);
  • 每个请求都创建新的 McpServer 实例: 如果你在每次 POST 时重新创建 new McpServer(...)(而不是每个会话重用同一个实例),后续调用将没有你的工具。使用会话映射来存储 { server, transport }

2. 缺少或不匹配的 mcp-session-id 现象: 400 Bad Request: No valid session ID provided。

解决: 始终包含初始化响应头中返回的精确会话 ID:

-H "mcp-session-id: 550e8400-e29b-41d4-a716-446655440000"

高级技巧:用于完全 HTTP 访问的低级处理程序

如果你绝对需要在工具内部访问原始的 HTTP 请求(用于 cookie、查询参数等),你可以绕过 server.tool(...),并为 CallToolRequestSchema 注册一个低级处理程序:

const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
const {
  CallToolRequestSchema
} = require('@modelcontextprotocol/sdk/types.js');

const server = new Server(
  { name: 'low-level-server', version: '1.0.0' },
  { capabilities: { tools: {} } }
);
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  // `request` 将包含:
  //  • request.params.name, request.params.arguments
  //  • request.transportContext (在某些传输实现中可能包含原始 HTTP)
  // 你可以手动读取 `request.transportContext.headers`(如果传输层支持它)。
});
const transport = new StdioServerTransport();
await server.connect(transport);

然而,截至 2025 年中,MCP 的高级 SDK 不会将原始 HTTP 注入到高级的server.tool(...)回调中。如果你需要完全访问 HTTP,请使用这种低级模式。

更多内容请查看 Cursor中文文档

更多精彩Cursor开发技巧博客地址

更多Cursor使用技巧也可关注公众号 AI近距离

好了, 到这里文章就接近尾声了, 本文详细的介绍了:

  1. 什么是 MCP
  2. 为什需要 MCP
  3. 如何实现 Express MCP 服务器(含完整代码)
  4. 如何测试验证 MCP

认真按照步骤操作,你可以快速在 Node.js 中构建一个健壮的 MCP 服务器。