前几节大家把 MCP Server 跑起来了用的都是 stdio 模式——Cursor 或 Client 直接在本地启动 Server 进程通过 stdin/stdout 通信。本地开发没问题但一到生产环境就卡住了工具服务要独立部署多个 Agent 都能调用stdio 就不够用了。这节讲 SSE 模式——把 MCP Server 变成一个普通的 HTTP 服务任意 Client 都可以通过网络连进来。一、stdio 和 SSE 的本质区别我用一张图说清楚stdio 模式Host/Client 进程 → fork → Server 子进程通过 stdin/stdout 通信Server 和 Client 必须在同一台机器一个 Server 只服务一个 ClientSSE 模式MCP Server 独立部署标准 Spring Boot Web 服务Client 通过 HTTP 长连接SSE接收推送Server 独立运行可以同时服务多个 ClientServer 挂了不影响 Client 进程简单说stdio 是本地管道SSE 是 HTTP 服务。大家在生产环境基本都会走 SSE。二、SSE MCP Server 实现直接在之前的mcp-tools-server项目上改不用新建项目。工具代码一行都不用改这是 Spring AI MCP 设计得很好的地方。第一步换依赖pom.xml把原来的spring-ai-starter-mcp-server换成spring-ai-starter-mcp-server-webmvcwebmvc 变体已经内置了 Web 容器不需要再单独加spring-boot-starter-web!-- 去掉原来的 spring-ai-starter-mcp-server -- !-- 换成 webmvc 变体 -- dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-mcp-server-webmvc/artifactId /dependency第二步改配置application.yml去掉web-application-type: noneSSE 模式需要启动 Web 容器其他加上端口和 MCP 配置server: port: 8090 # MCP Server 独立端口避开 Agent 应用的 8080 spring: ai: mcp: server: name: jichi-remote-tools version: 1.0.0 type: SYNC sse-message-endpoint: /mcp/messages # SSE 消息端点路径 logging: config: classpath:logback-spring.xml # SSE 模式日志不用限制正常输出就行主类和工具类不变启动后会自动暴露两个端点GET /sseClient 建立 SSE 长连接等待服务器推送POST /mcp/messagesClient 发送请求工具调用等常见问题改完启动报错或 curl 显示 ECONNREFUSED检查两个地方application.yml里有没有留着web-application-type: none——SSE 模式必须删掉这行否则 Web 容器不启动pom.xml有没有加spring-boot-starter-web两项都确认后mvn clean package重新打包启动日志里出现Tomcat started on port 8090说明 Server 正常了。验证服务是否启动正常# 访问 SSE 端点正常会挂起等待说明服务在跑 curl http://localhost:8090/sse三、SSE MCP Client在之前的mcp-tools-client项目里新增文件把连接本地 Server 的 stdio 传输层换成 SSE 传输层。注意项目里原来的LocalMcpClientConfig和ThirdPartyMcpConfig里的Bean要先屏蔽掉否则 Spring 启动时会同时初始化 stdio 连接找不到本地 jar 就报 Stream closed。最简单的方式是把旧配置类的Bean注释掉或者给两套配置加Profile区分。新建RemoteMcpClientConfig.javapackage com.jichi.mcp.client; import io.modelcontextprotocol.client.McpClient; import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; import io.modelcontextprotocol.spec.McpSchema; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration Slf4j public class RemoteMcpClientConfig { Bean public McpSyncClient remoteToolsClient() { // SSE 传输层只需要传 Server 的 URL HttpClientSseClientTransport transport HttpClientSseClientTransport.builder(http://localhost:8090) .build(); McpSyncClient client McpClient.sync(transport) .clientInfo(new McpSchema.Implementation(jichi-agent, 1.0.0)) .build(); // initialize() 内部自动完成握手通知不需要额外调用 McpSchema.InitializeResult result client.initialize(); log.info([MCP] 已连接远程 Server{} v{}, result.serverInfo().name(), result.serverInfo().version()); return client; } }对应的测试 Controllerpackage com.jichi.mcp.client; import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.spec.McpSchema; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; RestController RequestMapping(/api/remote-mcp) RequiredArgsConstructor public class RemoteMcpController { private final McpSyncClient remoteToolsClient; GetMapping(/tools) public ListString listTools() { return remoteToolsClient.listTools().tools().stream() .map(t - t.name() t.description()) .toList(); } PostMapping(/call) public String callTool( RequestParam String toolName, RequestBody MapString, Object args) { McpSchema.CallToolResult result remoteToolsClient.callTool( new McpSchema.CallToolRequest(toolName, args)); return result.content().stream() .filter(c - c instanceof McpSchema.TextContent) .map(c - ((McpSchema.TextContent) c).text()) .findFirst().orElse(无返回内容); } }测试# 查看远程 Server 提供的工具 curl http://localhost:8080/api/remote-mcp/tools # 调用远程工具 curl -X POST http://localhost:8080/api/remote-mcp/call?toolNamegetDateInfo \ -H Content-Type: application/json \ -d {}四、认证与安全内网环境一般靠网络隔离对外暴露的 MCP Server 就需要加认证了。我给两个方案按场景选。4.1 API Key简单直接够用就好在 Server 侧加一个 Filter通过FilterRegistrationBean显式注册明确指定拦截路径更可靠package com.jichi.mcp.server; import jakarta.servlet.*; import jakarta.servlet.http.*; import lombok.extern.slf4j.Slf4j; import java.io.IOException; Slf4j public class McpApiKeyFilter implements Filter { private final String validApiKey; public McpApiKeyFilter(String validApiKey) { this.validApiKey validApiKey; } Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpReq (HttpServletRequest) request; String apiKey httpReq.getHeader(X-API-Key); if (!validApiKey.equals(apiKey)) { log.warn([MCP] 非法访问来自 {}, httpReq.getRemoteAddr()); ((HttpServletResponse) response).sendError(401, Invalid API Key); return; } chain.doFilter(request, response); } }在配置类里注册并绑定到 MCP 相关路径package com.jichi.mcp.server; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class McpSecurityConfig { Bean public FilterRegistrationBeanMcpApiKeyFilter mcpApiKeyFilter() { String apiKey System.getenv(MCP_API_KEY); if (apiKey null) { throw new IllegalStateException(环境变量 MCP_API_KEY 未设置); } FilterRegistrationBeanMcpApiKeyFilter registration new FilterRegistrationBean(); registration.setFilter(new McpApiKeyFilter(apiKey)); registration.addUrlPatterns(/sse, /mcp/*); // 只拦截 MCP 端点 registration.setOrder(1); registration.setName(mcpApiKeyFilter); return registration; } }Client 连接时带上 Header// 注意MCP_API_KEY 环境变量必须设置否则 header value 为 null 会抛 NullPointerException String apiKey System.getenv(MCP_API_KEY); HttpClientSseClientTransport.Builder transportBuilder HttpClientSseClientTransport.builder(http://localhost:8090); if (apiKey ! null) { transportBuilder.customizeRequest(builder - builder.header(X-API-Key, apiKey)); } HttpClientSseClientTransport transport transportBuilder.build();本地测试时如果 Server 没有开 API Key 认证不要加customizeRequest直接.build()即可。五、stdio 还是 SSE怎么选我给大家一个简单的判断标准用 stdio本地开发调试Cursor 本地接入工具只需要在本机运行不想折腾部署用 SSE生产环境部署多个 Agent 共享同一批工具工具服务要独立升级不影响 Agent 应用重启工具需要访问内网资源数据库、内部 API需要做认证鉴权大家在公司做项目基本都是 SSE。个人 Cursor 插件用 stdio 就够了。
远程 MCP Server——SSE 传输与生产部署
前几节大家把 MCP Server 跑起来了用的都是 stdio 模式——Cursor 或 Client 直接在本地启动 Server 进程通过 stdin/stdout 通信。本地开发没问题但一到生产环境就卡住了工具服务要独立部署多个 Agent 都能调用stdio 就不够用了。这节讲 SSE 模式——把 MCP Server 变成一个普通的 HTTP 服务任意 Client 都可以通过网络连进来。一、stdio 和 SSE 的本质区别我用一张图说清楚stdio 模式Host/Client 进程 → fork → Server 子进程通过 stdin/stdout 通信Server 和 Client 必须在同一台机器一个 Server 只服务一个 ClientSSE 模式MCP Server 独立部署标准 Spring Boot Web 服务Client 通过 HTTP 长连接SSE接收推送Server 独立运行可以同时服务多个 ClientServer 挂了不影响 Client 进程简单说stdio 是本地管道SSE 是 HTTP 服务。大家在生产环境基本都会走 SSE。二、SSE MCP Server 实现直接在之前的mcp-tools-server项目上改不用新建项目。工具代码一行都不用改这是 Spring AI MCP 设计得很好的地方。第一步换依赖pom.xml把原来的spring-ai-starter-mcp-server换成spring-ai-starter-mcp-server-webmvcwebmvc 变体已经内置了 Web 容器不需要再单独加spring-boot-starter-web!-- 去掉原来的 spring-ai-starter-mcp-server -- !-- 换成 webmvc 变体 -- dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-mcp-server-webmvc/artifactId /dependency第二步改配置application.yml去掉web-application-type: noneSSE 模式需要启动 Web 容器其他加上端口和 MCP 配置server: port: 8090 # MCP Server 独立端口避开 Agent 应用的 8080 spring: ai: mcp: server: name: jichi-remote-tools version: 1.0.0 type: SYNC sse-message-endpoint: /mcp/messages # SSE 消息端点路径 logging: config: classpath:logback-spring.xml # SSE 模式日志不用限制正常输出就行主类和工具类不变启动后会自动暴露两个端点GET /sseClient 建立 SSE 长连接等待服务器推送POST /mcp/messagesClient 发送请求工具调用等常见问题改完启动报错或 curl 显示 ECONNREFUSED检查两个地方application.yml里有没有留着web-application-type: none——SSE 模式必须删掉这行否则 Web 容器不启动pom.xml有没有加spring-boot-starter-web两项都确认后mvn clean package重新打包启动日志里出现Tomcat started on port 8090说明 Server 正常了。验证服务是否启动正常# 访问 SSE 端点正常会挂起等待说明服务在跑 curl http://localhost:8090/sse三、SSE MCP Client在之前的mcp-tools-client项目里新增文件把连接本地 Server 的 stdio 传输层换成 SSE 传输层。注意项目里原来的LocalMcpClientConfig和ThirdPartyMcpConfig里的Bean要先屏蔽掉否则 Spring 启动时会同时初始化 stdio 连接找不到本地 jar 就报 Stream closed。最简单的方式是把旧配置类的Bean注释掉或者给两套配置加Profile区分。新建RemoteMcpClientConfig.javapackage com.jichi.mcp.client; import io.modelcontextprotocol.client.McpClient; import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; import io.modelcontextprotocol.spec.McpSchema; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration Slf4j public class RemoteMcpClientConfig { Bean public McpSyncClient remoteToolsClient() { // SSE 传输层只需要传 Server 的 URL HttpClientSseClientTransport transport HttpClientSseClientTransport.builder(http://localhost:8090) .build(); McpSyncClient client McpClient.sync(transport) .clientInfo(new McpSchema.Implementation(jichi-agent, 1.0.0)) .build(); // initialize() 内部自动完成握手通知不需要额外调用 McpSchema.InitializeResult result client.initialize(); log.info([MCP] 已连接远程 Server{} v{}, result.serverInfo().name(), result.serverInfo().version()); return client; } }对应的测试 Controllerpackage com.jichi.mcp.client; import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.spec.McpSchema; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; RestController RequestMapping(/api/remote-mcp) RequiredArgsConstructor public class RemoteMcpController { private final McpSyncClient remoteToolsClient; GetMapping(/tools) public ListString listTools() { return remoteToolsClient.listTools().tools().stream() .map(t - t.name() t.description()) .toList(); } PostMapping(/call) public String callTool( RequestParam String toolName, RequestBody MapString, Object args) { McpSchema.CallToolResult result remoteToolsClient.callTool( new McpSchema.CallToolRequest(toolName, args)); return result.content().stream() .filter(c - c instanceof McpSchema.TextContent) .map(c - ((McpSchema.TextContent) c).text()) .findFirst().orElse(无返回内容); } }测试# 查看远程 Server 提供的工具 curl http://localhost:8080/api/remote-mcp/tools # 调用远程工具 curl -X POST http://localhost:8080/api/remote-mcp/call?toolNamegetDateInfo \ -H Content-Type: application/json \ -d {}四、认证与安全内网环境一般靠网络隔离对外暴露的 MCP Server 就需要加认证了。我给两个方案按场景选。4.1 API Key简单直接够用就好在 Server 侧加一个 Filter通过FilterRegistrationBean显式注册明确指定拦截路径更可靠package com.jichi.mcp.server; import jakarta.servlet.*; import jakarta.servlet.http.*; import lombok.extern.slf4j.Slf4j; import java.io.IOException; Slf4j public class McpApiKeyFilter implements Filter { private final String validApiKey; public McpApiKeyFilter(String validApiKey) { this.validApiKey validApiKey; } Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpReq (HttpServletRequest) request; String apiKey httpReq.getHeader(X-API-Key); if (!validApiKey.equals(apiKey)) { log.warn([MCP] 非法访问来自 {}, httpReq.getRemoteAddr()); ((HttpServletResponse) response).sendError(401, Invalid API Key); return; } chain.doFilter(request, response); } }在配置类里注册并绑定到 MCP 相关路径package com.jichi.mcp.server; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class McpSecurityConfig { Bean public FilterRegistrationBeanMcpApiKeyFilter mcpApiKeyFilter() { String apiKey System.getenv(MCP_API_KEY); if (apiKey null) { throw new IllegalStateException(环境变量 MCP_API_KEY 未设置); } FilterRegistrationBeanMcpApiKeyFilter registration new FilterRegistrationBean(); registration.setFilter(new McpApiKeyFilter(apiKey)); registration.addUrlPatterns(/sse, /mcp/*); // 只拦截 MCP 端点 registration.setOrder(1); registration.setName(mcpApiKeyFilter); return registration; } }Client 连接时带上 Header// 注意MCP_API_KEY 环境变量必须设置否则 header value 为 null 会抛 NullPointerException String apiKey System.getenv(MCP_API_KEY); HttpClientSseClientTransport.Builder transportBuilder HttpClientSseClientTransport.builder(http://localhost:8090); if (apiKey ! null) { transportBuilder.customizeRequest(builder - builder.header(X-API-Key, apiKey)); } HttpClientSseClientTransport transport transportBuilder.build();本地测试时如果 Server 没有开 API Key 认证不要加customizeRequest直接.build()即可。五、stdio 还是 SSE怎么选我给大家一个简单的判断标准用 stdio本地开发调试Cursor 本地接入工具只需要在本机运行不想折腾部署用 SSE生产环境部署多个 Agent 共享同一批工具工具服务要独立升级不影响 Agent 应用重启工具需要访问内网资源数据库、内部 API需要做认证鉴权大家在公司做项目基本都是 SSE。个人 Cursor 插件用 stdio 就够了。