前几节大家把 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 只服务一个 Client
SSE 模式:
MCP Server 独立部署(标准 Spring Boot Web 服务)
Client 通过 HTTP 长连接(SSE)接收推送
Server 独立运行,可以同时服务多个 Client
Server 挂了不影响 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-webmvc,webmvc 变体已经内置了 Web 容器,不需要再单独加spring-boot-starter-web:
<!-- 去掉原来的 spring-ai-starter-mcp-server --> <!-- 换成 webmvc 变体 --> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId> </dependency>第二步:改配置
application.yml去掉web-application-type: none(SSE 模式需要启动 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 /sse:Client 建立 SSE 长连接,等待服务器推送POST /mcp/messages:Client 发送请求(工具调用等)
常见问题:改完启动报错或 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.java:
package 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; } }对应的测试 Controller:
package 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 List<String> listTools() { return remoteToolsClient.listTools().tools().stream() .map(t -> t.name() + ":" + t.description()) .toList(); } @PostMapping("/call") public String callTool( @RequestParam String toolName, @RequestBody Map<String, 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?toolName=getDateInfo" \ -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 FilterRegistrationBean<McpApiKeyFilter> mcpApiKeyFilter() { String apiKey = System.getenv("MCP_API_KEY"); if (apiKey == null) { throw new IllegalStateException("环境变量 MCP_API_KEY 未设置"); } FilterRegistrationBean<McpApiKeyFilter> 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 就够了。