关于Remote MCP功能的解析

这篇文章上次修改于 7 个月前,可能部分内容已经不适用,如有疑问可询问作者。

关于Remote MCP功能的解析

关于Remote MCP功能的解析

在昨天cline新发布的3.8.6版本的changelog之中,提到了针对mcp的新功能支持——remote MCP

无独有偶的是,windsurf与昨天发布的1.6.1版本之中,也更新了对remote mcp的支持

这次的更新其实并不是什么新奇的玩意儿,因为cursor在一个月前就支持了remote MCP服务了,这次的更新其实只是常规更新,补齐了友商原先就有的功能。

那么remote mcp serve是什么,它有什么功能呢?这得介绍一下另一种mcp通信形式——stdio形式讲起。

stdio transport

stdio Transport 是 Model Context Protocol (MCP) 的一种传输方式,它的MCP 服务器实际上是一个由host(cline/cursor/cluad-desktop等)启动和管理的本地进程。当Host需要使用该工具时,它会启动并管理这个进程,通过标准输入/输出与其通信。

它是怎么运行的?

简单来说,STDIO Transport 的工作原理就是像一个"本地小管家",由编辑器直接在本地开一个进程来运行。它的工作流程大概是这样的:

  1. 进程创建与初始化:

    当你打开 Cline/Cursor 并加载了 MCP 配置后,编辑器会根据 mcp.json 中的 command 和 args 信息,悄悄地在后台启动一个子进程。比如配置了 "command": "node", "args": ["mcp-server-example.js"],编辑器就会像你在终端输入 node mcp-server-example.js 一样启动这个服务。

  2. 数据交流机制:

    • 编辑器想让 MCP 服务做点什么时,就往这个进程的标准输入(STDIN)写入一条 JSON 消息
    • MCP 服务收到消息后,处理完请求,再通过标准输出(STDOUT)返回结果
    • 每条消息都用换行符分隔,就像两个程序在用纸条交流一样简单直接

  1. 生命周期:

    • MCP 服务是跟随编辑器的心跳的 —— 编辑器启动它,编辑器关闭它
    • 当你关闭编辑器或者重启 MCP 服务时,原来的进程会被干净利落地终止掉
    • 每个编辑器窗口通常会维护自己专属的 MCP 服务实例,互不干扰

  2. 调试与错误处理:

    • 服务可以通过标准错误(STDERR)输出日志,方便开发者调试
    • 如果服务崩溃了,编辑器通常会尝试重启它,就像一个尽职的"保姆"一样

MCP 服务和编辑器之间的这种通信方式非常类似于我们平时在终端中运行命令的方式,区别在于这里是由编辑器自动管理的,对用户来说是无感知的。整个过程就像编辑器有了一个随叫随到的"小助手",需要特定功能时就叫它出来帮忙,用完就让它待机。

具体让它跑起来需要...

首先我们需要在mcp.json里面进行配置,一个mcp-server可以直接使用docker部署,也可以手动编写一段python/java/typescript代码进行启动。

以用js写的mcp-server为例,它的配置信息通常是:

JSON
{
  "mcpServers": {
    "calculator-server": {
      "command": "node",
      "args": ["mcp-server-example.js"],
      "env": {}
    }
  }
}
Copy

我们的host读取到配置文件之后,会按照配置文件里的command信息启动本地的服务,而这些服务里面会使用MCP官方文档中提供的SDK,完成mcp服务的书写。

一个使用了MCP官方提供的sdk例子可以是:

TYPESCRIPT
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import * as process from 'process';

/**
 * 定义计算记录的接口
 */
interface CalculationRecord {
  operation: string;
  a: number;
  b: number;
  result: number;
  timestamp: string;
}

/**
 * 1. 创建MCP服务器实例
 */
const server = new McpServer({
  name: "calculator-server",
  version: "1.0.0"
});

/**
 * 存储计算历史记录的数组
 */
const calculationHistory: CalculationRecord[] = [];

/**
 * 2. 定义工具
 * 直接使用tool方法定义工具及其处理逻辑
 */
server.tool(
  "calculate_sum",
  { a: z.number(), b: z.number() },
  async ({ a, b }) => {
    const result = a + b;
    // 记录计算历史
    calculationHistory.push({
      operation: "add",
      a: a,
      b: b,
      result: result,
      timestamp: new Date().toISOString()
    });

    return {
      content: [
        {
          type: "text",
          text: String(result)
        }
      ]
    };
  }
);

/**
 * 添加获取历史记录的工具函数
 */
server.tool(
  "get_calculation_history",
  {},
  async () => {
    if (calculationHistory.length === 0) {
      return {
        content: [
          {
            type: "text",
            text: "暂无计算历史记录"
          }
        ]
      };
    }

    // 格式化历史记录
    const historyText = calculationHistory.map((record, index) => {
      return `${index + 1}. ${record.timestamp}: ${record.a} + ${record.b} = ${record.result}`;
    }).join('\n');

    return {
      content: [
        {
          type: "text",
          text: `计算历史记录:\n${historyText}`
        }
      ]
    };
  }
);

/**
 * 3. 添加历史记录资源
 */
server.resource(
  "calculation_history",
  "calc://history",
  async (uri) => {
    if (calculationHistory.length === 0) {
      return {
        contents: [
          {
            uri: uri.href,
            text: "暂无计算历史记录"
          }
        ]
      };
    }

    // 格式化历史记录
    const historyText = calculationHistory.map((record, index) => {
      return `${index + 1}. ${record.timestamp}: ${record.a} + ${record.b} = ${record.result}`;
    }).join('\n');

    return {
      contents: [
        {
          uri: uri.href,
          text: `计算历史记录:\n${historyText}`
        }
      ]
    };
  }
);

/**
 * 4. 使用stdio传输启动服务器
 */
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("服务器已启动并等待连接...");
}

main().catch((error) => {
  console.error("服务器错误:", error);
  process.exit(1);
});
Copy

我们在上面的mcp-server-example.js文件中利用mcp sdk的能力提供了一个mcp server,对这个server具有的tools能力进行了解释,并实现了tools具体的功能。

而启动在本地的stdio tranport,它有以下几个优势:

  1. 安全性:所有操作都在本地进行,不需要网络通信
  2. 低延迟:直接进程间通信,响应速度快
  3. 自动化:Host 会自动启动和停止进程
  4. 环境变量共享:可以通过mcp.json中的env字段轻松传递环境变量和配置

Remote transport

而remote transport是一个使用了网络协议进行通信的MCP服务器,不同于stdio形式的是,它不是一个直接由Host直接启动和管理的mcp serve,而是作为一个独立的 HTTP 服务运行,它可以运行在本地localhost 上(比如 http://localhost:9006/__mcp/sse),也可以部署在远程服务器上

它又是怎么跑起来的?

  1. 服务启动方式:

    • 与 STDIO 不同,SSE 服务不是由编辑器启动的,而是作为一个独立的 HTTP 服务运行
    • 可以运行在本地 localhost 上(比如 http://localhost:9006/__mcp/sse),也可以部署在远程服务器上
    • 服务启动后会一直运行着,等待各种客户端来连接

  2. 连接建立过程:

    • 当编辑器启动后,会根据 mcp.json 中的 url 配置,去连接这个服务
    • 连接是通过 HTTP 协议中的一种特殊机制 —— Server-Sent Events (SSE) 建立的
    • 编辑器会先发一个 HTTP GET 请求到服务的 events 端点,建立一个持久连接

  1. 数据交换过程:

    • 编辑器需要使用工具时,通过 HTTP POST 发送请求到服务器
    • 服务器处理后,不是立即在 POST 响应中返回结果,而是通过之前建立的 SSE 连接推送回来
    • 这种方式允许服务器随时主动向编辑器推送消息,而不用等编辑器来询问

  2. 多客户端支持:

    • 一个 SSE 服务可以同时接受多个编辑器实例的连接
    • 每个连接都是独立的,服务器能区分不同的客户端

这种机制听起来可能有点复杂,但其实就是把本地进程间通信换成了网络通信。最大的好处是服务可以集中部署在一个地方(比如公司内网或云服务器),所有人都连接同一个服务,共享资源和能力,这会比每个人在本地都安装一份工具要方便得多!

另外,由于它是基于 HTTP 的,我们可以像写一个web服务一样去进行书写我们的mcp服务,因此,可以将一个mcp服务集成到已有的在线服务之中,在已有的代码上添加一两个接口,利用已有的基建系统去完成一些tool的功能实现,从而更灵活的完成mcp serve的开发。

Make a try!

配置 Remote SSE 服务其实比 STDIO 要简单很多,因为你不需要管理本地进程,只需要知道服务的地址即可。同样的,我们在mcp.json配置文件中进行配置

JSON
{
  "mcpServers": {
    "remote-server": {
      "url": "http://localhost:3000/mcp/sse",
      // 除了基础的url,我们可以配置常见的headers属性
      "headers": {
        "Authorization": "Bearer your-token"
      },
      "alwaysAllow": ["tool3"],
      "disabled": false
    }
  }
}
Copy

而serve部分,我们参照上面stdio transport的例子,创建功能一模一样的mcp Serve

TYPESCRIPT
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';

/**
 * 定义计算记录的接口
 */
interface CalculationRecord {
  operation: string;
  a: number;
  b: number;
  result: number;
  timestamp: string;
}

/**
 * 存储计算历史记录的数组
 */
const calculationHistory: CalculationRecord[] = [];

/**
 * 创建MCP服务器实例
 */
const server = new McpServer({
  name: "calculator-server",
  version: "1.0.0"
});

/**
 * 定义工具:计算两数之和
 */
server.tool(
  'calculate_sum',
  { a: z.number(), b: z.number() },
  async ({ a, b }) => {
    const result = a + b;
    // 记录计算历史
    calculationHistory.push({
      operation: "add",
      a: a,
      b: b,
      result: result,
      timestamp: new Date().toISOString()
    });

    return {
      content: [
        {
          type: "text",
          text: String(result)
        }
      ]
    };
  },
);

/**
 * 定义工具:获取历史记录
 */
server.tool(
  'get_calculation_history',
  { random_string: z.string() },
  async () => {
    if (calculationHistory.length === 0) {
      return {
        content: [
          {
            type: "text",
            text: "暂无计算历史记录"
          }
        ]
      };
    }

    // 格式化历史记录
    const historyText = calculationHistory.map((record, index) => {
      return `${index + 1}. ${record.timestamp}: ${record.a} + ${record.b} = ${record.result}`;
    }).join('\n');

    return {
      content: [
        {
          type: "text",
          text: `计算历史记录:\n${historyText}`
        }
      ]
    };
  },
);
Copy

但是在mcp serve的创建部分,我们需要手动的去创建一个接口,它的接口路径应该与配置文件中的url一致。

TYPESCRIPT
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import express from 'express';
// ...

const PORT = process.env.PORT || 3000;
/**
 * 启动SSE传输服务器
 * 参考官方SSEServerTransport实现
 */
const startSseServer = async () => {
  console.log('启动SSE传输服务器...');

  // 创建Express应用
  const app = express();

  // SSE传输实例
  let transport: SSEServerTransport;

  // 设置SSE端点
  app.get('/mcp/sse', async (req, res) => {
    // 创建SSE传输
    transport = new SSEServerTransport('/mcp/messages', res);

    await server.connect(transport);
    return;
  });

  app.listen(PORT, () => {
    console.log(`Server is running on port ${PORT}`);
  });

  return true;
};
Copy

设置好了之后,host会创建mcp client尝试与我们写好的接口进行通信,而我们利用官方提供的SSEServerTransport去处理传来的信息,这里的transport主要做两件事情:

  1. 指定接收客户端消息的路径:第一个参数/mcp/messages指定了用于接收客户端消息的HTTP端点路径。当客户端需要向服务器发送请求时,会使用这个路径。
  2. 利用SSE连接与MCP服务器桥接:第二个参数res是Express的响应对象,SSEServerTransport将使用这个响应对象建立长连接,并通过它向客户端发送事件流,它将作为MCP服务器与客户端之间的通信桥梁,用于传递模型请求和响应。

在这个接口的基础上,我们还需要实现一个post服务,用于接受客户端消息路径并作为处理

TYPESCRIPT
  // 设置消息接收端点
  app.post('/messages', async (req, res) => {
    console.log('收到来自客户端的消息');

    await transport.handlePostMessage(req, res);
  });
Copy

实现完这些设置之后,就可以创建出一个可以调用mcpServe功能的服务端口。

原理剖析

SSEServerTransport是Model Context Protocol (MCP)框架中实现远程通信的关键组件,它利用Server-Sent Events (SSE)技术创建持久连接,使服务器能够实时向客户端推送数据,同时通过HTTP POST接收客户端请求。

它建立了一个双通道通信系统: - 客户端→服务器:通过标准HTTP POST请求发送命令和查询 - 服务器→客户端:通过持久SSE连接实时推送结果和通知

这种设计结合了HTTP的广泛兼容性和SSE的实时推送能力,非常适合AI工具等需要异步响应的场景。

初始化和连接建立的实现

TYPESCRIPT
constructor(_endpoint, res) {
    this._endpoint = _endpoint;
    this.res = res;
    this._sessionId = randomUUID();
}

async start() {
    this.res.writeHead(200, {
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache",
        Connection: "keep-alive",
    });
    // 发送端点信息,包含会话ID
    this.res.write(`event: endpoint\ndata: ${encodeURI(this._endpoint)}?sessionId=${this._sessionId}\n\n`);
    this._sseResponse = this.res;
}
Copy

这部分代码:

  • 创建唯一会话ID
  • 设置SSE所需的HTTP头
  • 建立持久连接
  • 发送端点信息给客户端

handlePostMessage的实现

当客户端发送请求时,handlePostMessage方法按以下步骤处理:

  1. 接收JSON信息
TYPESCRIPT
// 读取HTTP请求体,处理编码和大小限制
body = await getRawBody(req, {
    limit: MAXIMUM_MESSAGE_SIZE,  // 4MB
    encoding: ct.parameters.charset ?? "utf-8",
});
Copy

getRawBody就像是一个耐心的接收员,他会站在门口,一点一点接收数据流 - 如果数据太大(>4MB),直接拒绝"太大了不接收" - 把可能分散的多个数据片段拼成一个完整内容 - 查看内容的编码标签(charset)决定如何解析它 - 最后把完整、干净的内容交给下一步处理

这一步确保了无论客户端怎么发送数据(分块、流式等),都能被完整接收并准备好进行处理。

  1. JSON解析和验证
TYPESCRIPT
// 将字符串转换为JSON对象
await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body);
// ...
// 验证是否符合JSON-RPC 2.0规范
parsedMessage = JSONRPCMessageSchema.parse(message);
// 检查必需字段如jsonrpc、id、method等
Copy

这一步将原始数据转换为结构化对象并进行schema验证 - 检查接收到的body是否为字符串,如是,使用JSON.parse转换为typescript对象 - 验证消息是否符合JSON-RPC 2.0规范 - 检查必需字段(jsonrpc、id、method等)

  1. 消息分发 typescript // 将消息传递给MCP服务器的处理回调 this.onmessage?.call(this, parsedMessage); // 此回调会路由请求到正确的工具处理函数

将验证后的消息传递给处理回调: - 调用预先设置的onmessage回调函数 - 传入验证通过的JSON-RPC消息对象 - 负责路由消息到正确的工具处理函数

  1. MCP服务器处理

我们的mcpServer在收到message之后,会调用一开始利用tool定义的功能给予执行,在上述例子中是: typescript const result = a + b; // 例如210 + 123 = 333 calculationHistory.push({...});

服务器会在这一步中执行实际业务逻辑: - 从JSON-RPC参数中提取操作数(a和b) - 执行定义的计算操作 - 构造返回结果对象,准备将结果发送回客户端

  1. 通过sse返回结果 typescript this._sseResponse.write(`event: message\ndata: ${JSON.stringify(result)}\n\n`);

将结果发送给客户端: - 将结果对象序列化为JSON字符串 - 构造符合SSE格式的事件消息 - 通过持久的SSE连接发送给客户端

到此为止,我们就完成一次完整的请求-响应循环,cline agent就成功的调用了远程的serve。

Remote mcp的主要优点

在现代AI开发环境中,Remote MCP模式凭借其灵活性和强大的网络特性,可以更为灵活的进行开发。

  1. 多客户端支持:一个SSE服务器可以同时服务多个客户端,适合团队使用的场景
  2. 远程部署:不需要安装在每台电脑上,部署一次所有用户都能用
  3. 跨设备访问:任何能联网的设备都能连接到你的MCP服务
  4. 集中式更新:服务端更新一次,所有用户立刻获得新功能
  5. 标准HTTP安全:可以使用常规的Web安全机制,如HTTPS、认证等

选择建议

考虑因素STDIO(本地模式)SSE(远程模式)
位置📍 只能在本地机器上运行🌐 可以在本地或远程运行
客户端👤 单一用户👥 支持多用户同时连接
性能⚡ 超低延迟(直接进程通信)🐢 有网络延迟(但通常可接受)
配置复杂度😌 简单,开箱即用🧰 需要配置HTTP服务器
安全性🔐 天然安全(不出本机)🛡️ 需要额外安全措施(认证等)
网络要求🚫 不需要网络🌐 依赖网络连接
扩展性📊 受限于单机性能📈 可以跨网络分布式扩展
部署方式🔄 每个用户都要安装🚀 集中安装一次即可
更新方式📲 每个用户都要单独更新✨ 中央更新一次全员生效
资源占用💻 占用客户端资源☁️ 主要占用服务器资源
依赖管理📦 每个用户都要安装依赖📚 服务端统一管理依赖
适合场景个人工具、本地操作多、追求极致性能、不想依赖网络开箱即用、集成线上服务、统一管理、资源共享