1、项目简介
使用Spring MCP开发MCP服务器代码非常简单,下面是示例代码
kotlin
@Service
public class WeatherMcp{
@Tool(description = "根据城市名获取该城市的天气信息实况")
public String getWeatherByCityName(@ToolParam(description = "城市名称") String cityName) {
System.out.println("获取城市天气信息:" + cityName);
String nowUrl = "https://api.seniverse.com/v3/weather/now.json?key=" + apiKey + "&location=";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(nowUrl + cityName))
.build();
try {
var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println("获取城市天气信息成功:" + response.body());
return response.body();
} catch (Exception e) {
System.out.println(e.getMessage());
return "获取天气信息失败";
}
}
}
一个@Tool注解搞定,然后就是一个配置类,注册工具
kotlin
@Configuration
public class McpConfig {
@Bean
public ToolCallbackProvider WeatherTools(WeatherMcp weatherMcp) {
return MethodToolCallbackProvider.builder()
.toolObjects(weatherMcp)
.build();
}
}
然后是ymal配置及依赖
yaml
spring:
ai:
mcp:
server:
name: my-mcp-server
version: 1.0.0
type: SYNC
sse-message-endpoint: /mcp/messages
server:
port: 8989
servlet:
context-path: /
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
<version>1.0.0</version>
</dependency>
在Cherry Studio(自带mcp客户端)连接看下


这就成功写了一个mcp服务器,并且能够让大模型调用了。
2、问题提出
代码实现起来很简单,但是当大模型调用我们的MCP服务器的工具时发生了什么呢?先说结论,请看下面的图

总共请求了5次,第一次请求的path为/see,后续都为/mcp/messages,这些路径其实就是上面yml文件配置的。
在第一次http请求中, 本次请求会变成一个sse连接,同时创建一个McpSeesion,同时把sessionId以endpoint的方式推送给客户端。这个连接是1对1的,即一个客户端对应一个连接,后续请求的结果都是通过这个sse连接推送到对应的客户端
第二和第三次的http请求,用以初始化,具体干了啥没有具体研究
第四次http请求中,method是tools/list,用于获取mcp服务器的工具列表
第五次http请求中,method是tools/call,用于调用具体的mcp工具
3、/sse的请求链路
我们直接从DispatcherServlet.doDispatch方法开始看,在getHandler方法会获取HandlerExecutionChain,在HandlerMappings中有一个RouterFuncitionMapping,在这个对象中维护着RouterFunction,这里面有着所有的路由到function的映射。如下所示:
(GET && /sse) -> io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider$$Lambda$1305/0x00000004015d2710@3d8d970e 和
(POST && /mcp/messages) -> io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider$$Lambda$1307/0x00000004015d43f8@2fac80a8
这就对应着mcp的两个请求路径 也就是说,如果GET方法且是/sse路径,则会路由到WebMvcSseServerTransportProvider的一个Lambda表达式,POST方法且路径为 /mcp/messages路由到WebMvcSseServerTransportProvider的另一个Lambda表达式。 (这里提一嘴,我们使用@RequestMapping注解的controller中的方法是RequestMappingHandlerMapping中处理的)


再看WebMvcSseServerTransportProvider类

在初始化对象时添加了路由,也就是/sse映射到handleSseConnection方法 /mcp/messages 映射到handleMessage方法
思考一个问题,这个映射关系是什么时候,又是如何添加到HandlerMappings中的呢?
答案是在在DispatcherServlet的initStrategies方法中

在这里初始化了WebMvc的九大组件,其中就包括HandlerMappings的初始化。
看initHandlerMappings方法,就是把spring容器中初始化的HandlerMapping对象放到HandlerMappings中。
而上面的RouterFuncitionMapping应该是在springboot的自动装配类中自动装配到spring容器中的
再聚焦到HandlerMapping的getHandler方法,在这个方法会返回一个HandlerExecutionChain对象,这个对象里面有我们自定义和默认的拦截器和过滤器以及handler

在getHandler方法中,首先通过getHandlerInternal获取handler

在RouterFuncitionMapping中其实这个handler就是一个HandlerFunction(RequestMappingHandlerMapping则是一个HandlerMethod),而这个HandlerFunction就是WebMvcSseServerTransportProvider中添加的。

这个AuthInterceptor就是我自定义的拦截器,所以mcp的请求到这里还是跟普通的web请求一样,只不过使用的的是RouterFuncitionMapping,而不是平常使用的RequestMappingHandlerMapping,并且RouterFuncitionMapping的优先级高于RequestMappingHandlerMapping。

到这里通过url获取到了完整的HandlerExecutionChain,在HandlerExecutionChain中有handler(真正处理业务逻辑的方法或Lamda表达式)及拦截器

然后执行applyPreHandle,其实就是拦截器的前置处理方法,执行完后执行HandlerAdapter的handle方法。

然后进入handlerFunction的handle方法,即WebMvcSseServerTransportProvider类的handleSseConnection方法。在这里会创建一个sse连接



这里的sseBuilder.id(sessionId).event(MESSAGE_EVENT_TYPE).data();其实就是通过sse向客户端推送消息
到这里成功创建了一个McpServerSession,与客户端一对一连接。所谓的连接就是服务端维护这个sessionId,并把这个sessionId给到客户端,客户端下次调用mcp服务时带上这个sessionId。
其实这里忽略了许多细节,比如下面这个创建会话的代码,整个创建过程还是比较长的,挺多代码和回调啥的,就不细讲了。
ini
McpServerSession session = sessionFactory.create(sessionTransport);
4、/mcp/messages的请求链路
从dispatch到HandlerFunctionAdapter的过程跟sse是一样的,只不过handler的方法对应的是WebMvcSseServerTransportProvider的handleMessage方法。在handleMessage方法中,根据请求的sessionId,找到之前的创建的McpServerSession

然后从请求体中拿到request body

然后调用session.handle方法,在这个方法中根据message类型走不同的逻辑,而本次走的是request
kotlin
public Mono<Void> handle(McpSchema.JSONRPCMessage message) {
return Mono.defer(() -> {
// TODO handle errors for communication to without initialization happening
// first
if (message instanceof McpSchema.JSONRPCResponse response) {
logger.debug("Received Response: {}", response);
var sink = pendingResponses.remove(response.id());
if (sink == null) {
logger.warn("Unexpected response for unknown id {}", response.id());
}
else {
sink.success(response);
}
return Mono.empty();
}
else if (message instanceof McpSchema.JSONRPCRequest request) {
logger.debug("Received request: {}", request);
return handleIncomingRequest(request).onErrorResume(error -> {
var errorResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null,
new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR,
error.getMessage(), null));
// TODO: Should the error go to SSE or back as POST return?
return this.transport.sendMessage(errorResponse).then(Mono.empty());
}).flatMap(this.transport::sendMessage);
}
else if (message instanceof McpSchema.JSONRPCNotification notification) {
// TODO handle errors for communication to without initialization
// happening first
logger.debug("Received notification: {}", notification);
// TODO: in case of error, should the POST request be signalled?
return handleIncomingNotification(notification)
.doOnError(error -> logger.error("Error handling notification: {}", error.getMessage()));
}
else {
logger.warn("Received unknown message type: {}", message);
return Mono.empty();
}
});
}
聚焦下面代码
javascript
else if (message instanceof McpSchema.JSONRPCRequest request) {
logger.debug("Received request: {}", request);
return handleIncomingRequest(request).onErrorResume(error -> {
var errorResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null,
new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR,
error.getMessage(), null));
// TODO: Should the error go to SSE or back as POST return?
return this.transport.sendMessage(errorResponse).then(Mono.empty());
}).flatMap(this.transport::sendMessage);
}
handleIncomingRequest方法处理完后发送消息,即transport::sendMessage,这个send message就是往客户端推送结果

在上面的代码中,sseBuilder.id(sessionId).event(MESSAGE_EVENT_TYPE).data(jsonText); 其实就是通过sse向客户端推送消息,具体流程如下:



再看handleIncomingRequest方法,如果是初始化请求,走if的逻辑,如果是其他请求,比如toolList, call等方法走else。再看else里面的逻辑。会从requestHandlers获取到对应的RequestHandler,然后执行handler.handle方法并把结果返回

我们再看这个requestHandlers,它是一个Map
swift
private final Map<String, RequestHandler<?>> requestHandlers;
下面是requestHandlers的put方法,前面几个是预定义的一些方法,比如tools/list、tools/call等。

我们先看tools/list的requestHandler,如下, 这个方法返回了一个匿名内部类,其实就是requestHandler的实现类。
kotlin
private McpServerSession.RequestHandler<McpSchema.ListToolsResult> toolsListRequestHandler() {
return (exchange, params) -> {
List<Tool> tools = this.tools.stream().map(McpServerFeatures.AsyncToolSpecification::tool).toList();
return Mono.just(new McpSchema.ListToolsResult(tools, null));
};
}
到这里可以发现CopyOnWriteArrayList<McpServerFeatures.AsyncToolSpecification> tools就是所有mcp工具的集合
scss
public record AsyncToolSpecification(McpSchema.Tool tool,
BiFunction<McpAsyncServerExchange, Map<String, Object>, Mono<McpSchema.CallToolResult>> call) {
static AsyncToolSpecification fromSync(SyncToolSpecification tool) {
// FIXME: This is temporary, proper validation should be implemented
if (tool == null) {
return null;
}
return new AsyncToolSpecification(tool.tool(),
(exchange, map) -> Mono
.fromCallable(() -> tool.call().apply(new McpSyncServerExchange(exchange), map))
.subscribeOn(Schedulers.boundedElastic()));
}
}
再看tools/call的requestHandler,也是匿名内部类,最终调用我们自定义代码的地方应该是tool.call().apply(exchange, callToolRequest.arguments())方法。
less
private McpServerSession.RequestHandler<CallToolResult> toolsCallRequestHandler() {
return (exchange, params) -> {
McpSchema.CallToolRequest callToolRequest = objectMapper.convertValue(params,
new TypeReference<McpSchema.CallToolRequest>() {
});
Optional<McpServerFeatures.AsyncToolSpecification> toolSpecification = this.tools.stream()
.filter(tr -> callToolRequest.name().equals(tr.tool().name()))
.findAny();
if (toolSpecification.isEmpty()) {
return Mono.error(new McpError("Tool not found: " + callToolRequest.name()));
}
return toolSpecification.map(tool -> tool.call().apply(exchange, callToolRequest.arguments()))
.orElse(Mono.error(new McpError("Tool not found: " + callToolRequest.name())));
};
}
最后要解决的问题是,spring mcp是如何把我们自己写的方法转成一个mcp tool的?
关键在于创建MethodToolCallback对象,这是mcp方法回调对象,在上面的tool.call().apply方法中,最终会进入下面代码

在这里可以看到有一个ToolCallback对象,然后使用toolCallback.call方法调用到MethodToolCallback.call, 如下图


这里的invoke方法就是我们自己写的带有@Tool的方法。
此时再看我们写的McpConfig
kotlin
@Configuration
public class McpConfig {
@Bean
public ToolCallbackProvider WeatherTools(WeatherMcp weatherMcp) {
return MethodToolCallbackProvider.builder()
.toolObjects(weatherMcp)
.build();
}
}
typescript
public Builder toolObjects(Object... toolObjects) {
Assert.notNull(toolObjects, "toolObjects cannot be null");
this.toolObjects = Arrays.asList(toolObjects);
return this;
}
public MethodToolCallbackProvider build() {
return new MethodToolCallbackProvider(this.toolObjects);
}
scss
private MethodToolCallbackProvider(List<Object> toolObjects) {
Assert.notNull(toolObjects, "toolObjects cannot be null");
Assert.noNullElements(toolObjects, "toolObjects cannot contain null elements");
assertToolAnnotatedMethodsPresent(toolObjects);
this.toolObjects = toolObjects;
validateToolCallbacks(getToolCallbacks());
}
less
@Override
public ToolCallback[] getToolCallbacks() {
var toolCallbacks = this.toolObjects.stream()
.map(toolObject -> Stream
.of(ReflectionUtils.getDeclaredMethods(
AopUtils.isAopProxy(toolObject) ? AopUtils.getTargetClass(toolObject) : toolObject.getClass()))
.filter(toolMethod -> toolMethod.isAnnotationPresent(Tool.class))
.filter(toolMethod -> !isFunctionalType(toolMethod))
.map(toolMethod -> MethodToolCallback.builder()// 在这里创建MethodToolCallback对象
.toolDefinition(ToolDefinitions.from(toolMethod))
.toolMetadata(ToolMetadata.from(toolMethod))
.toolMethod(toolMethod)
.toolObject(toolObject)
.toolCallResultConverter(ToolUtils.getToolCallResultConverter(toolMethod))
.build())
.toArray(ToolCallback[]::new))
.flatMap(Stream::of)
.toArray(ToolCallback[]::new);
validateToolCallbacks(toolCallbacks);
return toolCallbacks;
}
5、总结
其实最关键的地方就两个映射。
一个是dispatcherServlet中把web请求映射到WebMvcSseServerTransportProvider的handleSseConnection和handleMessage方法。handleSseConnection方法用以创建sse连接及创建McpSession,并把sessionId推送给客户端,handleMessage用以处理客户端发送的消息,比如tools/list获取所有工具列表,tools/call用以调用具体的工具;
第二个关键点在于,在WebMvcSseServerTransportProvider的handleMessage方法中,如何根据客户端传过来的的方法名映射到我们自己写的带有@Tool的方法,其实就是在McpConfig配置类中,我们手动把工具注册成MethodToolCallback,MethodToolCallback有工具方法的元信息,其中包含方法名,回调Method,参数类型,返回值类型等。通过客户端传过来的方法名就很容易匹配到MethodToolCallback,然后使用反射回调即可。回调结束后会把回调结果以sendMessage的方式通过sse推送给客户端,而不是以web请求的response返回给客户端。